@galda/cli 0.10.35 → 0.10.36
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 +50 -11
- package/engine/lib.mjs +16 -0
- package/engine/server.mjs +139 -16
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -1546,6 +1546,20 @@
|
|
|
1546
1546
|
.fsbcol:hover .fsbcadd{opacity:.7}
|
|
1547
1547
|
.fsbcadd:hover{opacity:1;color:var(--ink);background:var(--hover)}
|
|
1548
1548
|
.fsbcadd svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
|
|
1549
|
+
/* Doing列の一時停止/再生トグル (Masa 2026-07-18・Spotify式). Running=hoverで出る一時停止
|
|
1550
|
+
❚❚ / paused=常時表示の再生 ▶ (停止状態が読める+再開できる). hoverは面を塗らずグリフ
|
|
1551
|
+
色のみ (DESIGN-RULES §5.5). 中立グレー: 緑/赤はstatusに読めるので使わない. board版=col
|
|
1552
|
+
ヘッダの Doing ラベル隣, list版=下の .tlcap-pause が #fsTodoSec の DOING 見出し隣. */
|
|
1553
|
+
.fsbcpause{width:20px;height:20px;display:grid;place-items:center;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:5px;opacity:0;transition:opacity .12s,color .12s}
|
|
1554
|
+
.fsbcol:hover .fsbcpause{opacity:.7}
|
|
1555
|
+
.fsbcpause:hover{opacity:1;color:var(--ink)}
|
|
1556
|
+
.fsbcpause[aria-pressed="true"]{opacity:1;color:var(--ink)}
|
|
1557
|
+
.fsbcpause svg{width:15px;height:15px;fill:currentColor;stroke:none}
|
|
1558
|
+
.tlcap-pause{display:inline-grid;place-items:center;width:15px;height:15px;vertical-align:-3px;margin-left:6px;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:4px;opacity:0;transition:opacity .12s,color .12s}
|
|
1559
|
+
#fsTodoSec:hover .tlcap-pause{opacity:.7}
|
|
1560
|
+
.tlcap-pause:hover{opacity:1;color:var(--ink)}
|
|
1561
|
+
.tlcap-pause[aria-pressed="true"]{opacity:1;color:var(--ink)}
|
|
1562
|
+
.tlcap-pause svg{width:11px;height:11px;fill:currentColor;stroke:none}
|
|
1549
1563
|
.fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
|
|
1550
1564
|
.fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
|
|
1551
1565
|
/* Board card = Depth (CDO, Masa 2026-07-17): a card is SCANNED, not read — #NN + a
|
|
@@ -5759,6 +5773,7 @@ function fsBoardBucket(goal, tasks){
|
|
|
5759
5773
|
const s = goal?.status;
|
|
5760
5774
|
if (s === 'pending') return 'later'; // shelved by the composer's "Later"
|
|
5761
5775
|
if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
|
|
5776
|
+
if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
|
|
5762
5777
|
if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
|
|
5763
5778
|
if (s === 'review') return 'review';
|
|
5764
5779
|
if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
|
|
@@ -5788,6 +5803,7 @@ function fsBoardBucket(goal, tasks){
|
|
|
5788
5803
|
// above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
|
|
5789
5804
|
function renderFsBoard(){
|
|
5790
5805
|
const el = $('fsBoard'); if (!el) return;
|
|
5806
|
+
const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
|
|
5791
5807
|
const { pgoals, list } = fsBucketTodo();
|
|
5792
5808
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
|
|
5793
5809
|
const goalNo = new Map();
|
|
@@ -5798,7 +5814,7 @@ function renderFsBoard(){
|
|
|
5798
5814
|
// flagship mock; a new goal enters via the composer). Earlier this was To-Do-only, which read
|
|
5799
5815
|
// as "position is off" against the Artifact (Masa 2026-07-18).
|
|
5800
5816
|
const col = (name, stg, count, html, stage) =>
|
|
5801
|
-
`<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
|
|
5817
|
+
`<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span>${stage === 'doing' ? `<button class="fsbcpause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button>` : ''}<span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
|
|
5802
5818
|
// Depth card (CDO, Masa 2026-07-17): #NN + title, colour on the dot only, and a continuous
|
|
5803
5819
|
// stroke + n/m ONLY while the goal is actually in flight. The column already names the
|
|
5804
5820
|
// status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
|
|
@@ -5810,9 +5826,11 @@ function renderFsBoard(){
|
|
|
5810
5826
|
// not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
|
|
5811
5827
|
const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
|
|
5812
5828
|
const intr = g.status === 'interrupted';
|
|
5813
|
-
const
|
|
5829
|
+
const isPaused = g.status === 'blocked' && g.blocked?.kind === 'paused'; // 一時停止=エラーでなくアンバー扱い
|
|
5830
|
+
const amber = intr || isPaused;
|
|
5831
|
+
const healthyRun = bkt === 'doing' && !errored; // paused(=errored) goals now sit in 'doing' too but carry no progress bar
|
|
5814
5832
|
let dot = '', word = '';
|
|
5815
|
-
if (errored) { dot =
|
|
5833
|
+
if (errored) { dot = amber ? 'intr' : 'bad'; word = isPaused ? 'Paused' : intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
|
|
5816
5834
|
else if (bkt === 'review') dot = 'rev';
|
|
5817
5835
|
else if (bkt === 'done') dot = 'ok';
|
|
5818
5836
|
else if (bkt === 'needs') dot = 'intr'; // your turn — a clarifying question is waiting
|
|
@@ -5823,7 +5841,7 @@ function renderFsBoard(){
|
|
|
5823
5841
|
let h = '<div class="lbc-hd">'
|
|
5824
5842
|
+ (dot ? `<span class="lbc-dot ${dot}"></span>` : '')
|
|
5825
5843
|
+ (n != null ? `<span class="lbc-n">#${n}</span>` : '')
|
|
5826
|
-
+ (word ? `<span class="lbc-w ${
|
|
5844
|
+
+ (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
|
|
5827
5845
|
+ '</div>'
|
|
5828
5846
|
+ `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
|
|
5829
5847
|
// n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
|
|
@@ -5854,8 +5872,22 @@ function renderFsBoard(){
|
|
|
5854
5872
|
+ col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
|
|
5855
5873
|
+ col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
|
|
5856
5874
|
for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
|
|
5875
|
+
for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // Doing列の一時停止/再生
|
|
5857
5876
|
renderFsWait(bucket);
|
|
5858
5877
|
}
|
|
5878
|
+
// Doing列の一時停止/再生 (Masa 2026-07-18・Spotify式トグル). 楽観的にローカルの
|
|
5879
|
+
// project.paused を反転して即描画→POST. サーバは pause/resume 後に {ev:'projects'}
|
|
5880
|
+
// を broadcast するので、確定状態はSSEハンドラが state.projects を上書きして一致
|
|
5881
|
+
// させる. POST失敗時だけ元に戻す. board/list 両方の [data-fspause] が共有する。
|
|
5882
|
+
function togglePauseActive(){
|
|
5883
|
+
const p = state.projects.find((x) => x.id === state.active);
|
|
5884
|
+
if (!p) return;
|
|
5885
|
+
const next = !p.paused;
|
|
5886
|
+
p.paused = next; render();
|
|
5887
|
+
fetch(withKey(`/api/projects/${state.active}/${next ? 'pause' : 'resume'}`), { method: 'POST', headers: { 'content-type': 'application/json' } })
|
|
5888
|
+
.then((r) => { if (!r.ok) throw new Error(`pause toggle ${r.status}`); })
|
|
5889
|
+
.catch(() => { p.paused = !next; render(); });
|
|
5890
|
+
}
|
|
5859
5891
|
// Waiting badge (CDO, Masa 2026-07-17 decision 2): the board keeps work-flow column order, so
|
|
5860
5892
|
// the "your turn" columns (Needs you, Review) can sit off the right edge at a narrow lane
|
|
5861
5893
|
// width. They announce themselves beside the List/Board toggle instead — pure read of the same
|
|
@@ -6045,6 +6077,10 @@ function fsBucketTodo(){
|
|
|
6045
6077
|
function renderFsTodo(){
|
|
6046
6078
|
const el = $('fsTodoSec'); if (!el) return;
|
|
6047
6079
|
const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
|
|
6080
|
+
const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
|
|
6081
|
+
// 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
|
|
6082
|
+
// Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
|
|
6083
|
+
const pausedGoalIds = new Set(pgoals.filter((g) => g.status === 'blocked' && g.blocked?.kind === 'paused').map((g) => g.id));
|
|
6048
6084
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
|
|
6049
6085
|
// LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
|
|
6050
6086
|
// PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
|
|
@@ -6063,8 +6099,10 @@ function renderFsTodo(){
|
|
|
6063
6099
|
// out to its own "Needs you" caption below (Masa 2026-07-19) — folding it into Doing read
|
|
6064
6100
|
// as "still in flight".
|
|
6065
6101
|
const doingRows =
|
|
6066
|
-
running.map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
|
|
6067
|
-
<div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
|
|
6102
|
+
running.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
|
|
6103
|
+
<div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
|
|
6104
|
+
// 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
|
|
6105
|
+
+ pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('');
|
|
6068
6106
|
// NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
|
|
6069
6107
|
// it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
|
|
6070
6108
|
// keeping the affordances it already had — nothing about the failed-task and blocked-goal
|
|
@@ -6081,7 +6119,7 @@ function renderFsTodo(){
|
|
|
6081
6119
|
// both still call the same /retry endpoint, which already resumes the session when one
|
|
6082
6120
|
// exists (server-side retry = "resume if possible, else re-run"; there is no separate
|
|
6083
6121
|
// resume endpoint to call).
|
|
6084
|
-
failed.map((t) => { const isInt = t.status === 'interrupted';
|
|
6122
|
+
failed.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
|
|
6085
6123
|
const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
|
|
6086
6124
|
// don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
|
|
6087
6125
|
// when the task has no distinct result text — the coloured word already said it, so
|
|
@@ -6092,7 +6130,7 @@ function renderFsTodo(){
|
|
|
6092
6130
|
return `<div class="drow err${isInt ? ' int' : ''}"><div class="trow now"><span class="st run ${isInt ? 'int' : 'bad'}"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="stword ${isInt ? 'int' : ''}">${word}</span>${fsActs(t.goalId)}</div>
|
|
6093
6131
|
<div class="errline">${reasonLine}
|
|
6094
6132
|
<div class="acts2"><button class="errbtn" data-fsretry="${t.id}">${label}</button><button class="errbtn" data-fslog="${t.id}">View log</button></div></div></div>`; }).join('')
|
|
6095
|
-
+ blocked.map((g) => `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
|
|
6133
|
+
+ blocked.map((g) => g.blocked?.kind === 'paused' ? '' : `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
|
|
6096
6134
|
<div class="errline"><div class="msg"><span class="x">✗</span>${tt(g.blocked?.reason ?? 'blocked', 180)}</div>
|
|
6097
6135
|
<div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
|
|
6098
6136
|
+ needsInput.map((g) => `<div class="drow err int"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
|
|
@@ -6137,9 +6175,9 @@ function renderFsTodo(){
|
|
|
6137
6175
|
: `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
|
|
6138
6176
|
el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
|
|
6139
6177
|
<div class="tl">
|
|
6140
|
-
${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
|
|
6141
|
-
${needsRows ? `<span class="tlcap needs"${doingRows ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6142
|
-
${upRows ? `<span class="tlcap"${doingRows || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
|
|
6178
|
+
${(doingRows || paused) ? `<span class="tlcap tlcap-doing" style="padding-top:0">Doing<button class="tlcap-pause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button></span>${doingRows}` : ''}
|
|
6179
|
+
${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6180
|
+
${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
|
|
6143
6181
|
${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
|
|
6144
6182
|
${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
|
|
6145
6183
|
<button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
|
|
@@ -6159,6 +6197,7 @@ function renderFsTodo(){
|
|
|
6159
6197
|
fsUI.repsOpen.has(id) ? fsUI.repsOpen.delete(id) : fsUI.repsOpen.add(id);
|
|
6160
6198
|
b.closest('.grp').classList.toggle('open');
|
|
6161
6199
|
};
|
|
6200
|
+
for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // list版 Doing 一時停止/再生(+Paused行のResume)
|
|
6162
6201
|
for (const b of el.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
|
|
6163
6202
|
for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
|
|
6164
6203
|
for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
|
package/engine/lib.mjs
CHANGED
|
@@ -1375,6 +1375,22 @@ export function classifyComposerIntentHeuristic(text) {
|
|
|
1375
1375
|
return 'unsure'; // both or neither → let the LLM decide
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
|
+
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). A DETERMINISTIC,
|
|
1379
|
+
// conservative match — no LLM, no cost — that only fires when the WHOLE message
|
|
1380
|
+
// is a short, command-like pause/resume order. Anchored ^…$ with only polite
|
|
1381
|
+
// suffixes allowed after the keyword, plus a length cap, so a described request
|
|
1382
|
+
// that merely contains the word ("止めてほしいバグを直して", "start building the
|
|
1383
|
+
// login") is never mistaken for a control command. Returns 'pause'|'resume'|null.
|
|
1384
|
+
const PAUSE_INTENT_RE = /^(一時停止|いったん停止|一旦停止|停止|止めて|とめて|ストップ|作業を?止めて|全部止めて|全部停止|pause|stop)(?:\s*(して|してください|ください|でお願いします?|お願いします?|please|now))?[\s。.!!、,]*$/i;
|
|
1385
|
+
const RESUME_INTENT_RE = /^(再開|再生|再スタート|続けて|続きから|続行|resume|restart|start|play|go)(?:\s*(して|してください|ください|お願いします?|please|now))?[\s。.!!、,]*$/i;
|
|
1386
|
+
export function detectPauseIntent(text) {
|
|
1387
|
+
const t = (text || '').trim();
|
|
1388
|
+
if (!t || t.length > 24) return null; // a control command is short; long text is a real request
|
|
1389
|
+
if (PAUSE_INTENT_RE.test(t)) return 'pause';
|
|
1390
|
+
if (RESUME_INTENT_RE.test(t)) return 'resume';
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1378
1394
|
// The one prompt that BOTH classifies and (for chat) writes the reply in the
|
|
1379
1395
|
// requester's language — one Haiku round-trip, not two. The model returns a
|
|
1380
1396
|
// single line of JSON: {"intent":"chat|task","reply":"…"}.
|
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, makeSigninChallenge, parseClaimResponse } 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, parseFailingTestNames, classifyTestGate, 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, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } 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, parseFailingTestNames, classifyTestGate, 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 } from './lib.mjs';
|
|
25
25
|
import { openPR } from './pr.mjs';
|
|
26
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
27
27
|
|
|
@@ -657,11 +657,17 @@ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
|
|
|
657
657
|
const goalId = nextId++, taskId = nextId++;
|
|
658
658
|
goals.push({
|
|
659
659
|
id: goalId, projectId: 'default', text: '[verify fixture] 実行中ゴール', status: 'running',
|
|
660
|
+
// startedAt is what a REAL running goal carries (server.mjs sets it when the
|
|
661
|
+
// first task starts — see runTask). Without it the board bucketed this as
|
|
662
|
+
// To Do while the list showed it under Doing (task-level) — the two views
|
|
663
|
+
// disagreed on a malformed fixture. Set it so the seed matches reality:
|
|
664
|
+
// both views place a running goal in Doing (Masa 2026-07-19).
|
|
665
|
+
startedAt: at,
|
|
660
666
|
wantsPR: false, model: 'sonnet', images: [], plan: ['fixture task'], pr: undefined, createdAt: at,
|
|
661
667
|
});
|
|
662
668
|
tasks.push({
|
|
663
669
|
id: taskId, num: 1, goalId, projectId: 'default', title: 'fixture task', detail: '', passCondition: '',
|
|
664
|
-
model: 'sonnet', status: 'running', createdAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
|
|
670
|
+
model: 'sonnet', status: 'running', createdAt: at, startedAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
|
|
665
671
|
activity: [
|
|
666
672
|
'Read app/index.html', 'Grep taskBlock', 'Edit app/index.html', 'Read engine/lib.mjs',
|
|
667
673
|
'Bash node --test engine/test/*.test.mjs', '✓ 12 tests passed', 'Edit engine/server.mjs',
|
|
@@ -1302,12 +1308,22 @@ function globalRunningCount() {
|
|
|
1302
1308
|
return n;
|
|
1303
1309
|
}
|
|
1304
1310
|
|
|
1311
|
+
// Per-project play/pause (Masa: Doing列の一時停止/再生). A paused project neither
|
|
1312
|
+
// starts new stacked goals nor pumps queued tasks — the two choke points below
|
|
1313
|
+
// (startNextStacked / pump) read this, so EVERY caller of them is covered by one
|
|
1314
|
+
// guard each. The flag lives on the project object (projects.json), so it
|
|
1315
|
+
// survives a restart; nothing auto-resumes a paused project.
|
|
1316
|
+
function isProjectPaused(projectId) {
|
|
1317
|
+
return !!projects.find((p) => p.id === projectId)?.paused;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1305
1320
|
// Goals stack up per project: while one goal is being planned or executed,
|
|
1306
1321
|
// newer goals wait as 'stacked' (editable / deletable until picked up).
|
|
1307
1322
|
function goalActive(projectId) {
|
|
1308
1323
|
return goals.some((g) => g.projectId === projectId && ['planning', 'running'].includes(g.status));
|
|
1309
1324
|
}
|
|
1310
1325
|
function startNextStacked(projectId) {
|
|
1326
|
+
if (isProjectPaused(projectId)) return; // paused → don't begin planning queued goals
|
|
1311
1327
|
if (goalActive(projectId)) return;
|
|
1312
1328
|
const next = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
|
|
1313
1329
|
.sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id)[0];
|
|
@@ -1496,6 +1512,9 @@ function pump(projectId) {
|
|
|
1496
1512
|
// to sign `claude` in and relaunch). Never spawn a worker that can only fail
|
|
1497
1513
|
// with "Not logged in". Cleared automatically once a probe succeeds.
|
|
1498
1514
|
if (!workerAuth.ok) return;
|
|
1515
|
+
// Project paused by the user (Doing列の一時停止 / chat) → hold this project's
|
|
1516
|
+
// queue exactly like the auth hold above. Resume flips the flag and pumps.
|
|
1517
|
+
if (isProjectPaused(projectId)) return;
|
|
1499
1518
|
const q = queues.get(projectId);
|
|
1500
1519
|
// sortQueueByPriority is a stable sort: it only ever moves a task ahead of
|
|
1501
1520
|
// a lower-priority one, never disturbs relative order within the same
|
|
@@ -1729,6 +1748,48 @@ function rateLimitResumeSweep() {
|
|
|
1729
1748
|
const rateLimitSweepTimer = setInterval(rateLimitResumeSweep, RATE_LIMIT_SWEEP_MS);
|
|
1730
1749
|
rateLimitSweepTimer.unref?.();
|
|
1731
1750
|
|
|
1751
|
+
// Project play/pause (Masa: Doing列の一時停止ボタン + チャット). Pause STOPS the
|
|
1752
|
+
// project's in-flight Doing work and prevents new ToDo from starting; Resume
|
|
1753
|
+
// continues from where it left off. Modelled on the rate-limit pause above (a
|
|
1754
|
+
// SIGTERM'd worker becomes an 'interrupted' task with its sessionId preserved,
|
|
1755
|
+
// so runTask's retry/resume path re-continues the same conversation) — the only
|
|
1756
|
+
// differences are: it's user-driven (no resumeAt / auto-resume), and it flips
|
|
1757
|
+
// the persisted project.paused flag that gates pump()/startNextStacked().
|
|
1758
|
+
//
|
|
1759
|
+
// Race note: setting goal.status='blocked' HERE (synchronously) BEFORE the
|
|
1760
|
+
// SIGTERM'd worker's async exit runs means finishGoalIfComplete later sees a
|
|
1761
|
+
// non-'running'/'partial' goal and bails at its status guard without clobbering
|
|
1762
|
+
// the paused state — see finishGoalIfComplete's early return.
|
|
1763
|
+
function pauseProject(projectId) {
|
|
1764
|
+
const project = projects.find((p) => p.id === projectId);
|
|
1765
|
+
if (!project || project.paused) return false;
|
|
1766
|
+
project.paused = true;
|
|
1767
|
+
saveProjects();
|
|
1768
|
+
for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'running')) {
|
|
1769
|
+
killGoalWorkers(g.id); // SIGTERM → task becomes 'interrupted' (resumable), not cancelled
|
|
1770
|
+
g.status = 'blocked';
|
|
1771
|
+
g.blocked = { kind: 'paused', reason: 'paused by user — resume to continue' };
|
|
1772
|
+
saveGoal(g);
|
|
1773
|
+
}
|
|
1774
|
+
send({ ev: 'projects', projects });
|
|
1775
|
+
return true;
|
|
1776
|
+
}
|
|
1777
|
+
function resumeProject(projectId) {
|
|
1778
|
+
const project = projects.find((p) => p.id === projectId);
|
|
1779
|
+
if (!project || !project.paused) return false;
|
|
1780
|
+
project.paused = false;
|
|
1781
|
+
saveProjects();
|
|
1782
|
+
// Re-queue each paused goal's interrupted tasks and flip it back to running
|
|
1783
|
+
// (reuses the proven rate-limit resume: continues from the preserved session).
|
|
1784
|
+
for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'blocked' && g.blocked?.kind === 'paused')) {
|
|
1785
|
+
resumeRateLimitedGoal(g);
|
|
1786
|
+
}
|
|
1787
|
+
startNextStacked(projectId);
|
|
1788
|
+
pump(projectId);
|
|
1789
|
+
send({ ev: 'projects', projects });
|
|
1790
|
+
return true;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1732
1793
|
// Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
|
|
1733
1794
|
// runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
|
|
1734
1795
|
function queueReplyTask(goal, text) {
|
|
@@ -2365,31 +2426,66 @@ async function runProjectTests(dir) {
|
|
|
2365
2426
|
// Failing tests on the BASE (pre-change) checkout — the set the review gate
|
|
2366
2427
|
// subtracts so a goal is blocked only on failures IT introduced, never on a
|
|
2367
2428
|
// test that was already red (e.g. persist-failure on clean origin/main). Lazy
|
|
2368
|
-
// + cached by the base
|
|
2369
|
-
//
|
|
2370
|
-
//
|
|
2429
|
+
// + cached by the base-ref sha in MANAGER_HOME: computed only when a goal's own
|
|
2430
|
+
// run is red, then reused across goals sharing the same base (main moves a few
|
|
2431
|
+
// times a day, so this is a handful of full runs a day, not one per goal).
|
|
2371
2432
|
// Any failure to compute → [] = "unknown", and the caller falls back to the
|
|
2372
2433
|
// old block-on-any-red behaviour (fail closed).
|
|
2434
|
+
//
|
|
2435
|
+
// 🔴 The base MUST be the ref the goal worktree was branched from —
|
|
2436
|
+
// origin/<default> (goalWorkDir: `git worktree add … origin/<br>`), NOT the
|
|
2437
|
+
// registry dir's working HEAD. Those diverge in the wild: a dogfood checkout
|
|
2438
|
+
// left on an old branch, or uncommitted local work. Measuring the baseline on a
|
|
2439
|
+
// stale HEAD runs a suite where today's red tests don't exist yet → an EMPTY
|
|
2440
|
+
// failing set → the gate falls closed and blocks EVERY goal on a red that was
|
|
2441
|
+
// actually pre-existing. (Masa 2026-07-19: the registry dir sat 612 commits
|
|
2442
|
+
// behind origin/main, so every goal Failed on "テスト失敗 1件" the baseline
|
|
2443
|
+
// should have excused.) So resolve origin/<default> and run the base suite in a
|
|
2444
|
+
// throwaway detached worktree of that exact commit — nested under
|
|
2445
|
+
// repoRoot/.manager-wt so node's upward node_modules resolution still finds the
|
|
2446
|
+
// repo's deps (the same trick goal worktrees rely on), and so we never mutate
|
|
2447
|
+
// the registry checkout (prod procs read it; it may be on any branch).
|
|
2373
2448
|
const BASELINE_CACHE = join(DATA_DIR, 'test-baseline.json');
|
|
2449
|
+
const baselineInFlight = new Map(); // sha -> Promise<string[]> — dedupe concurrent goal closes
|
|
2374
2450
|
async function getBaselineFailures(baseProject) {
|
|
2375
2451
|
const dir = baseProject?.dir;
|
|
2376
2452
|
if (!dir) return [];
|
|
2377
|
-
let
|
|
2378
|
-
try {
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2453
|
+
let repoRoot;
|
|
2454
|
+
try { repoRoot = gitSync(dir, ['rev-parse', '--show-toplevel']); } catch { return []; }
|
|
2455
|
+
const br = repoDefaultBranch(dir);
|
|
2456
|
+
try { gitSync(repoRoot, ['fetch', 'origin', br, '--quiet']); } catch { /* offline — use whatever origin/<br> we already have */ }
|
|
2457
|
+
// The base ref the goal worktree is cut from: origin/<br>, else the local
|
|
2458
|
+
// branch tip, else the working HEAD (offline single-checkout fallback).
|
|
2459
|
+
const sha = (gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${br}`])
|
|
2460
|
+
|| gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', br])
|
|
2461
|
+
|| gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD']) || '').trim();
|
|
2382
2462
|
if (!sha) return [];
|
|
2383
2463
|
try {
|
|
2384
2464
|
const cached = JSON.parse(readFileSync(BASELINE_CACHE, 'utf8'));
|
|
2385
2465
|
if (cached && cached.sha === sha && Array.isArray(cached.failingNames)) return cached.failingNames;
|
|
2386
2466
|
} catch { /* no cache yet */ }
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2467
|
+
if (baselineInFlight.has(sha)) return baselineInFlight.get(sha);
|
|
2468
|
+
const job = (async () => {
|
|
2469
|
+
const wt = join(repoRoot, '.manager-wt', `baseline-${sha.slice(0, 12)}`);
|
|
2470
|
+
let base = null;
|
|
2471
|
+
try {
|
|
2472
|
+
try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* nothing to clean */ }
|
|
2473
|
+
gitSync(repoRoot, ['worktree', 'add', '--detach', wt, sha]);
|
|
2474
|
+
base = await runProjectTests(wt);
|
|
2475
|
+
} catch {
|
|
2476
|
+
// Worktree couldn't be made (odd repo / offline no-remote) — fall back to
|
|
2477
|
+
// the registry dir in place = the pre-fix behaviour. Never worse.
|
|
2478
|
+
try { base = await runProjectTests(dir); } catch { base = null; }
|
|
2479
|
+
} finally {
|
|
2480
|
+
try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
|
|
2481
|
+
}
|
|
2482
|
+
if (!base?.ran) return [];
|
|
2483
|
+
const failingNames = base.failingNames ?? [];
|
|
2484
|
+
try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
|
|
2485
|
+
return failingNames;
|
|
2486
|
+
})();
|
|
2487
|
+
baselineInFlight.set(sha, job);
|
|
2488
|
+
try { return await job; } finally { baselineInFlight.delete(sha); }
|
|
2393
2489
|
}
|
|
2394
2490
|
|
|
2395
2491
|
// ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
|
|
@@ -3272,6 +3368,18 @@ const server = createServer(async (req, res) => {
|
|
|
3272
3368
|
return;
|
|
3273
3369
|
}
|
|
3274
3370
|
|
|
3371
|
+
// Per-project play/pause (Masa: Doing列の一時停止/再生・チャットからも同じ). POST
|
|
3372
|
+
// .../pause stops in-flight Doing work + holds the queue; .../resume continues
|
|
3373
|
+
// from where it left off. Broadcasts the projects change so every client's
|
|
3374
|
+
// toggle updates. Idempotent: pausing an already-paused project is a 200 no-op.
|
|
3375
|
+
const pauseMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/(pause|resume)$/);
|
|
3376
|
+
if (pauseMatch && req.method === 'POST') {
|
|
3377
|
+
const project = projects.find((p) => p.id === pauseMatch[1]);
|
|
3378
|
+
if (!project) return json(res, 404, { error: 'project not found' });
|
|
3379
|
+
if (pauseMatch[2] === 'pause') pauseProject(project.id); else resumeProject(project.id);
|
|
3380
|
+
return json(res, 200, { project, projects, paused: !!project.paused });
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3275
3383
|
// proof artifacts: /proof/<taskId>/<file> (gif/png/mp4 from that task's proof dir)
|
|
3276
3384
|
const proofMatch = url.pathname.match(/^\/proof\/(\d+)\/([\w.-]+)$/);
|
|
3277
3385
|
if (proofMatch) {
|
|
@@ -3301,6 +3409,21 @@ const server = createServer(async (req, res) => {
|
|
|
3301
3409
|
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
|
|
3302
3410
|
const project = projects.find((p) => p.id === projectId);
|
|
3303
3411
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
3412
|
+
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
|
|
3413
|
+
// (no LLM), checked BEFORE the intent router below: a short command-like
|
|
3414
|
+
// "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
|
|
3415
|
+
// — it never becomes a To-Do. /later and /review skip this like the router.
|
|
3416
|
+
if (!pending && !review) {
|
|
3417
|
+
const pauseIntent = detectPauseIntent(text.trim());
|
|
3418
|
+
if (pauseIntent === 'pause') {
|
|
3419
|
+
pauseProject(projectId);
|
|
3420
|
+
return json(res, 200, { kind: 'chat', reply: `${project.name} を一時停止しました。動いていた作業を止め、新しいToDoも始めません。再生(▶)で続きから再開します。` });
|
|
3421
|
+
}
|
|
3422
|
+
if (pauseIntent === 'resume') {
|
|
3423
|
+
resumeProject(projectId);
|
|
3424
|
+
return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3304
3427
|
// Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
|
|
3305
3428
|
// composer is conversation-first. A chat/help/settings message ("what can
|
|
3306
3429
|
// you do?", "reply in English", a greeting) gets a conversational reply
|
package/package.json
CHANGED