@galda/cli 0.10.98 → 0.10.100
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 +105 -32
- package/engine/lib.mjs +22 -4
- package/engine/requirement-model.mjs +13 -3
- package/engine/requirement-verification.mjs +53 -0
- package/engine/server.mjs +46 -6
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -1389,6 +1389,9 @@
|
|
|
1389
1389
|
Was a pulsing dot (fsrundot). Centre WITHOUT transform (spin's rotate would override a centring
|
|
1390
1390
|
translate → drift bottom-right) via top/left:50% + negative-half margin; concentric with the 4px dot. */
|
|
1391
1391
|
#fsRoot .fsproj.run i{background:none;box-shadow:none;position:relative}
|
|
1392
|
+
/* .fsproj.on i and .fsproj.run i have equal specificity. Keep the running
|
|
1393
|
+
ring visible when the currently selected project is also running. */
|
|
1394
|
+
#fsRoot .fsproj.on.run i{background:none;box-shadow:none}
|
|
1392
1395
|
/* Review-blue comparison baseline: the Doing cue is a 7px / 1.3px broken
|
|
1393
1396
|
ring, while idle and Review remain 4px dots. */
|
|
1394
1397
|
#fsRoot .fsproj.run i::after{content:"";position:absolute;top:50%;left:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;box-sizing:border-box;border-radius:50%;border:1.3px solid var(--green);border-top-color:transparent;animation:spin 1.8s linear infinite}
|
|
@@ -1920,7 +1923,7 @@
|
|
|
1920
1923
|
box-shadow:inset 0 0 0 1px rgba(var(--green-rgb),.30);font:600 12px/1 var(--ui);cursor:pointer;display:inline-flex;align-items:center;gap:7px}
|
|
1921
1924
|
.sa-app:hover{box-shadow:inset 0 0 0 1px var(--green);background:rgba(var(--green-rgb),.12)}
|
|
1922
1925
|
.sa-app svg{width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
|
|
1923
|
-
.sa-approve-wrap{display:flex;flex-direction:column;align-items:
|
|
1926
|
+
.sa-approve-wrap{display:flex;flex-direction:column;align-items:center;gap:5px;flex:0 0 auto}
|
|
1924
1927
|
.sa-approve-wrap + .sa-dis{align-self:flex-start}
|
|
1925
1928
|
.sa-delivery{font:500 9.5px/1.15 var(--mono);color:var(--ink3);text-decoration:none;white-space:nowrap}
|
|
1926
1929
|
a.sa-delivery{color:var(--green)}
|
|
@@ -3472,6 +3475,28 @@ function restoreFocusedAnswerInput(container, attr, saved){
|
|
|
3472
3475
|
el.focus();
|
|
3473
3476
|
try { el.setSelectionRange(saved.start, saved.end); } catch { /* input type may not support it */ }
|
|
3474
3477
|
}
|
|
3478
|
+
// The capture/restore pair above still loses an in-progress Japanese/Chinese/Korean
|
|
3479
|
+
// IME conversion: rebuilding the DOM node while the browser has an open composition
|
|
3480
|
+
// session aborts that session outright, so the half-typed conversion is dropped even
|
|
3481
|
+
// though the plain .value is restored. Track composition state at the document level
|
|
3482
|
+
// (delegated, since the actual input node gets destroyed and recreated every rebuild)
|
|
3483
|
+
// and let callers skip the destructive innerHTML rebuild while composing, retrying
|
|
3484
|
+
// once composition ends.
|
|
3485
|
+
let composingAnswerInput = null; // { attr, id } while a qansinput/fsansinput IME session is open
|
|
3486
|
+
document.addEventListener('compositionstart', (e) => {
|
|
3487
|
+
const attr = e.target?.getAttribute?.('data-qansinput') != null ? 'data-qansinput'
|
|
3488
|
+
: e.target?.getAttribute?.('data-fsansinput') != null ? 'data-fsansinput' : null;
|
|
3489
|
+
if (attr) composingAnswerInput = { attr, id: e.target.getAttribute(attr) };
|
|
3490
|
+
});
|
|
3491
|
+
document.addEventListener('compositionend', (e) => {
|
|
3492
|
+
if (!composingAnswerInput) return;
|
|
3493
|
+
if (e.target?.getAttribute?.(composingAnswerInput.attr) !== composingAnswerInput.id) return;
|
|
3494
|
+
composingAnswerInput = null;
|
|
3495
|
+
// Catch up on whatever rebuild(s) were skipped mid-composition.
|
|
3496
|
+
if (typeof renderTasks === 'function') renderTasks();
|
|
3497
|
+
if (typeof renderFsFeed === 'function') renderFsFeed();
|
|
3498
|
+
});
|
|
3499
|
+
function answerInputComposing(attr){ return composingAnswerInput?.attr === attr; }
|
|
3475
3500
|
|
|
3476
3501
|
const STATUS_LABEL = {
|
|
3477
3502
|
sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input', needsApproval: 'Needs you',
|
|
@@ -4804,6 +4829,7 @@ function renderTasks(){
|
|
|
4804
4829
|
<div class="cond">shelved — press Start to queue it</div></div>
|
|
4805
4830
|
<button type="button" class="prmini pstart" data-pstart="${g.id}">Start</button>
|
|
4806
4831
|
<button type="button" class="pdel" data-pdel="${g.id}" title="Delete">✕</button></div>`).join('');
|
|
4832
|
+
if (answerInputComposing('data-qansinput')) return; // mid-IME conversion — rebuilding now would abort it; compositionend re-runs this
|
|
4807
4833
|
const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
|
|
4808
4834
|
$('tasklist').innerHTML =
|
|
4809
4835
|
sec('Needs you', needsYou, 'needsyou')
|
|
@@ -6532,8 +6558,11 @@ function renderFsRail(){
|
|
|
6532
6558
|
// project the To Do/Kanban view showed as Doing kept a static dot in the rail
|
|
6533
6559
|
// (Masa report 2026-07-27: "全部doingなのに緑のアイコンが回ってない"). Match the
|
|
6534
6560
|
// same bucket the board/list already use, minus Paused (intentionally static).
|
|
6535
|
-
|
|
6536
|
-
|
|
6561
|
+
// A live task is the strongest running signal. Keep the goal-bucket fallback
|
|
6562
|
+
// for Doing goals that have not created their first task yet.
|
|
6563
|
+
const running = ptasks.some((t) => t.status === 'running')
|
|
6564
|
+
|| pgoals.some((g) => fsBoardBucket(g, ptasks) === 'doing'
|
|
6565
|
+
&& !(g.status === 'blocked' && g.blocked?.kind === 'paused'));
|
|
6537
6566
|
const td = ptasks.filter((t) => ['running', 'queued'].includes(t.status)).length;
|
|
6538
6567
|
// The rail's eye icon is the review/attention count. Needs-you goals
|
|
6539
6568
|
// (needsInput/needsApproval/blocked, minus Paused) block on the person the
|
|
@@ -6762,6 +6791,7 @@ function renderFsFeed(){
|
|
|
6762
6791
|
$('fsRoot').classList.toggle('clawdhero', !hasWork);
|
|
6763
6792
|
const sc = feed.parentNode;
|
|
6764
6793
|
const nearBottom = sc.scrollHeight - sc.clientHeight - sc.scrollTop <= 80;
|
|
6794
|
+
if (answerInputComposing('data-fsansinput')) return; // mid-IME conversion — rebuilding now would abort it; compositionend re-runs this
|
|
6765
6795
|
const savedFsAns = captureFocusedAnswerInput(feed, 'data-fsansinput');
|
|
6766
6796
|
feed.innerHTML = hasWork ? out : '';
|
|
6767
6797
|
if (nearBottom) sc.scrollTop = sc.scrollHeight;
|
|
@@ -8511,15 +8541,40 @@ window.addEventListener('focus', () => { if (signinPending) refreshAfterSignin()
|
|
|
8511
8541
|
|
|
8512
8542
|
// When the served UI is newer than the one this tab is running (hot-copy
|
|
8513
8543
|
// or release), reload in place — drafts survive via localStorage.
|
|
8544
|
+
//
|
|
8545
|
+
// A new task/goal running elsewhere routinely touches app/index.html's mtime
|
|
8546
|
+
// (its own edit, or a hot-copy after another lane's merge) — that alone used
|
|
8547
|
+
// to force this reload on every open tab, mid-review, wiping the open Review
|
|
8548
|
+
// dialog with no way back in (Masa 2026-07-29). Two fixes: (1) never reload
|
|
8549
|
+
// while the Review dialog (SA.open) is up — remember a pending version and
|
|
8550
|
+
// apply it the moment the dialog closes instead; (2) whichever card was open
|
|
8551
|
+
// is saved to localStorage first, so if a reload does happen while reviewing,
|
|
8552
|
+
// boot() reopens the same card instead of dropping the user back at nothing.
|
|
8553
|
+
let _pendingUiVersion = null;
|
|
8514
8554
|
function checkUiVersion(v){
|
|
8515
8555
|
if (!v) return;
|
|
8516
8556
|
if (window.__uiv && window.__uiv !== v) {
|
|
8517
|
-
|
|
8557
|
+
if (typeof SA !== 'undefined' && SA.open) { _pendingUiVersion = v; return; }
|
|
8558
|
+
try {
|
|
8559
|
+
localStorage.setItem('draft', $('input').value ?? '');
|
|
8560
|
+
if (typeof SA !== 'undefined' && SA.open) {
|
|
8561
|
+
localStorage.setItem('reopenReview', JSON.stringify({ goalId: SA.solo || SA.focusGid || SA.cur, solo: !!SA.solo }));
|
|
8562
|
+
} else {
|
|
8563
|
+
localStorage.removeItem('reopenReview');
|
|
8564
|
+
}
|
|
8565
|
+
} catch {}
|
|
8518
8566
|
location.reload();
|
|
8519
8567
|
return;
|
|
8520
8568
|
}
|
|
8521
8569
|
window.__uiv = v;
|
|
8522
8570
|
}
|
|
8571
|
+
// Called from saClose() so a version that arrived mid-review gets applied
|
|
8572
|
+
// (reloaded) right after the user is done, instead of never at all.
|
|
8573
|
+
function applyPendingUiVersionIfAny(){
|
|
8574
|
+
if (_pendingUiVersion == null) return;
|
|
8575
|
+
const v = _pendingUiVersion; _pendingUiVersion = null;
|
|
8576
|
+
checkUiVersion(v);
|
|
8577
|
+
}
|
|
8523
8578
|
|
|
8524
8579
|
async function refresh(){
|
|
8525
8580
|
try {
|
|
@@ -8817,6 +8872,13 @@ async function boot(){
|
|
|
8817
8872
|
if (window.__GALDA_DISCONNECTED) return bootDisconnected(window.__GALDA_DISCONNECTED);
|
|
8818
8873
|
$('input').value = localStorage.getItem('draft') ?? '';
|
|
8819
8874
|
await refresh();
|
|
8875
|
+
// Reopen the Review dialog a version-triggered reload had to close mid-review
|
|
8876
|
+
// (see checkUiVersion) so the user lands back where they were, not on the board.
|
|
8877
|
+
try {
|
|
8878
|
+
const reopen = JSON.parse(localStorage.getItem('reopenReview') ?? 'null');
|
|
8879
|
+
localStorage.removeItem('reopenReview');
|
|
8880
|
+
if (reopen && typeof saOpen === 'function') saOpen(reopen.goalId, reopen.solo);
|
|
8881
|
+
} catch {}
|
|
8820
8882
|
// The folder sits above the composer and is read on every screen, so its list is
|
|
8821
8883
|
// fetched once at boot rather than on first open: without HOME the bar can only
|
|
8822
8884
|
// print the absolute path (measured — it read "/var/folders/…/agent-manager"
|
|
@@ -9020,32 +9082,15 @@ function resolveComposer(raw){
|
|
|
9020
9082
|
review: !!(msg.review || parsed.review),
|
|
9021
9083
|
};
|
|
9022
9084
|
}
|
|
9023
|
-
//
|
|
9024
|
-
//
|
|
9025
|
-
//
|
|
9026
|
-
//
|
|
9027
|
-
//
|
|
9028
|
-
//
|
|
9029
|
-
// planning/stacked, or nothing selected) → new-goal mode, exactly as before.
|
|
9030
|
-
const REPLY_AUTO = new Set(['review']);
|
|
9031
|
-
const REPLY_PLAIN = new Set(['running', 'partial', 'failed', 'interrupted', 'blocked']);
|
|
9085
|
+
// The centre composer is always a NEW task. `selectedGoal` is viewing state: using
|
|
9086
|
+
// it as an implicit reply target made a request typed while reading Review #856 get
|
|
9087
|
+
// appended to that unfinished goal. Replying is intentionally available only from
|
|
9088
|
+
// the goal/review card's dedicated Reply / Fix controls, whose destination is shown
|
|
9089
|
+
// next to the control. Never infer a delivery destination from what happens to be
|
|
9090
|
+
// open in the centre lane.
|
|
9032
9091
|
function replyBinding(){
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
// Focus and reply-target are separate concepts. A Review reply keeps this goal
|
|
9036
|
-
// selected so its live work remains in the centre feed, while the composer goes
|
|
9037
|
-
// back to new-goal mode and shows no stale “Replying to” chip.
|
|
9038
|
-
if (state.composerDetachedGoal === id) return null;
|
|
9039
|
-
const g = state.goals.find((x) => x.id === id);
|
|
9040
|
-
if (!g || g.projectId !== state.active) return null; // stale after a project switch → new-goal mode
|
|
9041
|
-
// needsInput has NO worker session yet (planGoal() sets it before any task/worker
|
|
9042
|
-
// exists — see engine/server.mjs) — there's no "conversation" to resume via /reply
|
|
9043
|
-
// (that endpoint 409s on needsInput; engine/lib.mjs:3010). Binding here routes to
|
|
9044
|
-
// the SAME /answer endpoint the question card's own free-text box already uses
|
|
9045
|
-
// (fsAnswerQuestion), so the composer becomes a second way to answer, not a new path.
|
|
9046
|
-
if (g.status === 'needsInput' && g.question) return { goal: g, mode: 'answer' };
|
|
9047
|
-
if (REPLY_AUTO.has(g.status)) return { goal: g, mode: 'auto' };
|
|
9048
|
-
if (REPLY_PLAIN.has(g.status)) return { goal: g, mode: 'plain' };
|
|
9092
|
+
// Deliberately do not bind the main composer. Keep this one routing boundary so
|
|
9093
|
+
// a future UI change cannot accidentally reintroduce selectedGoal -> /reply.
|
|
9049
9094
|
return null;
|
|
9050
9095
|
}
|
|
9051
9096
|
async function send(){
|
|
@@ -10331,6 +10376,24 @@ function saRender(){
|
|
|
10331
10376
|
const previousBody = S.querySelector('.sa-body');
|
|
10332
10377
|
const previousScrollTop = previousBody?.scrollTop ?? 0;
|
|
10333
10378
|
const restoreScroll = Boolean(previousBody) && SA.focusGid == null;
|
|
10379
|
+
// A reply changes the height of the card being replied to (new thread line, status
|
|
10380
|
+
// change) — restoring a raw scrollTop pixel then leaves the viewport at the same
|
|
10381
|
+
// number but showing DIFFERENT content, a visible jolt on whatever card the person
|
|
10382
|
+
// was actually reading (2026-07-29). Anchor on that card's own goalId instead: find
|
|
10383
|
+
// the topmost visible card before the rebuild, and after the rebuild scroll so that
|
|
10384
|
+
// SAME card sits at the SAME on-screen position, regardless of how much other cards
|
|
10385
|
+
// above it grew or shrank.
|
|
10386
|
+
let anchorGid = null, anchorOffset = 0;
|
|
10387
|
+
if (restoreScroll) {
|
|
10388
|
+
const containerTop = previousBody.getBoundingClientRect().top;
|
|
10389
|
+
// The card actually being read sits near the middle of the viewport, not whatever
|
|
10390
|
+
// sliver of a previous card's bottom edge still peeks in at the very top.
|
|
10391
|
+
const midY = containerTop + previousBody.clientHeight / 2;
|
|
10392
|
+
for (const el of previousBody.querySelectorAll('.sa-item')) {
|
|
10393
|
+
const r = el.getBoundingClientRect();
|
|
10394
|
+
if (r.top <= midY && r.bottom >= midY) { anchorGid = el.dataset.gid; anchorOffset = r.top - containerTop; break; }
|
|
10395
|
+
}
|
|
10396
|
+
}
|
|
10334
10397
|
const focusedReplyGoal = document.activeElement?.matches?.('#seeall .sa-chat')
|
|
10335
10398
|
? document.activeElement.dataset.goal
|
|
10336
10399
|
: null;
|
|
@@ -10355,7 +10418,6 @@ function saRender(){
|
|
|
10355
10418
|
SA.lastMarkup = markup;
|
|
10356
10419
|
S.innerHTML = markup;
|
|
10357
10420
|
const body = S.querySelector('.sa-body');
|
|
10358
|
-
if (body && restoreScroll) body.scrollTop = previousScrollTop;
|
|
10359
10421
|
// Once the person scrolls, their position wins over the short opening-focus
|
|
10360
10422
|
// animation. Programmatic opening scroll fires this too, which is fine: the
|
|
10361
10423
|
// first placement happened and later SSE ticks should preserve it.
|
|
@@ -10367,7 +10429,18 @@ function saRender(){
|
|
|
10367
10429
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
10368
10430
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
10369
10431
|
// and wire each one's toggle to keep that set current.
|
|
10370
|
-
paintReplyNote(); // the answer/question under a card's reply box survives its rebuild
|
|
10432
|
+
paintReplyNote(); // the answer/question under a card's reply box survives its rebuild — this is
|
|
10433
|
+
// also what actually grows a replied-to card's height, so the scroll anchor
|
|
10434
|
+
// below must run AFTER it, not before (2026-07-29 jolt fix).
|
|
10435
|
+
if (body && restoreScroll) {
|
|
10436
|
+
const anchorEl = anchorGid != null ? body.querySelector(`.sa-item[data-gid="${anchorGid}"]`) : null;
|
|
10437
|
+
if (anchorEl) {
|
|
10438
|
+
const newOffset = anchorEl.getBoundingClientRect().top - body.getBoundingClientRect().top;
|
|
10439
|
+
body.scrollTop += (newOffset - anchorOffset);
|
|
10440
|
+
} else {
|
|
10441
|
+
body.scrollTop = previousScrollTop;
|
|
10442
|
+
}
|
|
10443
|
+
}
|
|
10371
10444
|
for (const det of S.querySelectorAll('[data-detkey]')) {
|
|
10372
10445
|
det.addEventListener('toggle', () => {
|
|
10373
10446
|
const k = det.dataset.detkey;
|
|
@@ -10495,7 +10568,7 @@ function saOpen(focusGoalId, solo = false){
|
|
|
10495
10568
|
saFetchDiffs();
|
|
10496
10569
|
saEnhanceShotBoxes();
|
|
10497
10570
|
}
|
|
10498
|
-
function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; SA.lastMarkup = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
10571
|
+
function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; SA.lastMarkup = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); applyPendingUiVersionIfAny(); }
|
|
10499
10572
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
10500
10573
|
// ↑↓ and auto-advance to the next pending after each verdict — same screen, same
|
|
10501
10574
|
// Ledger, so approving flows without hunting for the next one.
|
package/engine/lib.mjs
CHANGED
|
@@ -2148,16 +2148,34 @@ export function wantsPullRequest(text) {
|
|
|
2148
2148
|
return /プルリク|pull\s*request|(^|[^A-Za-z])PR([^A-Za-z]|$)/i.test(String(text ?? ''));
|
|
2149
2149
|
}
|
|
2150
2150
|
|
|
2151
|
+
// Auto delivery must distinguish a request to CHANGE the connected project
|
|
2152
|
+
// from a request to THINK about it. A project-level PR preference remains the
|
|
2153
|
+
// user's strongest default, and an explicit Deliverable choice below always
|
|
2154
|
+
// wins. This only prevents the silent "implemented, locally previewed, but no
|
|
2155
|
+
// PR" outcome for ordinary implementation requests in projects that have not
|
|
2156
|
+
// configured that preference yet.
|
|
2157
|
+
export function isImplementationRequest(text) {
|
|
2158
|
+
const value = String(text ?? '');
|
|
2159
|
+
const implementation = /実装|修正|直して|直す|不具合|バグ|追加|変更|作って|できるように|直したい|fix\b|implement\b|build\b|add\b|change\b|bug\b/i;
|
|
2160
|
+
const explorationOnly = /リサーチ|調査|分析|アイデア|案(?:を|が|は|ください)|提案|比較|相談|教えて|説明|要約|デザイン|モック|ワイヤーフレーム|資料|スライド|レポート|ドキュメント|research\b|analysis\b|brainstorm\b|ideas?\b|suggest(?:ion)?\b|design\b|mockup\b|wireframe\b|slides?\b|report\b/i;
|
|
2161
|
+
// "実装案を出して" is exploration; "案3を実装して" is implementation.
|
|
2162
|
+
return implementation.test(value) && !(explorationOnly.test(value) && !/実装|修正|直して|直す|追加|変更|fix\b|implement\b|build\b|add\b|change\b/i.test(value));
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2151
2165
|
// Task 46: the "Deliverable" toggle (pr | none | auto) on task creation and
|
|
2152
2166
|
// editing. An explicit 'pr'/'none' always wins over the text heuristic, so
|
|
2153
2167
|
// a user's choice survives editing the goal's text afterwards. `projectDefault`
|
|
2154
2168
|
// (task 45: a project's reviewDefinition.defaultWantsPR, edited from the same
|
|
2155
2169
|
// settings panel as the review definition) is only consulted for 'auto' when
|
|
2156
2170
|
// the text itself gives no signal either way — an explicit "PRを作って" in the
|
|
2157
|
-
// text still wins even if the project's default is off
|
|
2158
|
-
//
|
|
2159
|
-
|
|
2160
|
-
|
|
2171
|
+
// text still wins even if the project's default is off. `preferenceKnown`
|
|
2172
|
+
// distinguishes an intentional per-project "No PR" from an older project
|
|
2173
|
+
// which has never made a delivery choice. Only the latter gets the helpful
|
|
2174
|
+
// implementation-request fallback.
|
|
2175
|
+
export function resolveWantsPR(pr, text, projectDefault = false, preferenceKnown = false) {
|
|
2176
|
+
if (pr === 'pr') return true;
|
|
2177
|
+
if (pr === 'none') return false;
|
|
2178
|
+
return wantsPullRequest(text) || !!projectDefault || (!preferenceKnown && isImplementationRequest(text));
|
|
2161
2179
|
}
|
|
2162
2180
|
|
|
2163
2181
|
// Task 90: which entry point created a goal (Web chat / MCP / future API),
|
|
@@ -63,16 +63,26 @@ function normalizeEvidence(value) {
|
|
|
63
63
|
const requirementId = cleanText(value?.requirementId);
|
|
64
64
|
const attemptId = cleanText(value?.attemptId);
|
|
65
65
|
if (!requirementId || !attemptId) return null;
|
|
66
|
-
const claim = CLAIM_STATUSES.has(value.claim) ? value.claim :
|
|
66
|
+
const claim = CLAIM_STATUSES.has(value.claim) ? value.claim : null;
|
|
67
67
|
const coverage = COVERAGE_STATUSES.has(value.coverage) ? value.coverage : 'unverified';
|
|
68
|
+
const workerEvidence = cleanText(value.workerEvidence ?? value.evidence);
|
|
69
|
+
const workerFiles = [...new Set([
|
|
70
|
+
...(Array.isArray(value.workerFiles) ? value.workerFiles : []),
|
|
71
|
+
...(Array.isArray(value.files) ? value.files : []),
|
|
72
|
+
].filter((item) => typeof item === 'string' && item))];
|
|
73
|
+
const observedFiles = [...new Set((Array.isArray(value.observedFiles) ? value.observedFiles : []).filter((item) => typeof item === 'string' && item))];
|
|
68
74
|
return {
|
|
69
75
|
requirementId,
|
|
70
76
|
attemptId,
|
|
71
77
|
taskId: value.taskId ?? null,
|
|
72
78
|
claim,
|
|
73
79
|
coverage,
|
|
74
|
-
evidence:
|
|
75
|
-
files: [...new Set(
|
|
80
|
+
evidence: workerEvidence,
|
|
81
|
+
files: [...new Set([...workerFiles, ...observedFiles])],
|
|
82
|
+
workerEvidence,
|
|
83
|
+
workerFiles,
|
|
84
|
+
observedFiles,
|
|
85
|
+
checks: Array.isArray(value.checks) ? value.checks : [],
|
|
76
86
|
};
|
|
77
87
|
}
|
|
78
88
|
|
|
@@ -66,3 +66,56 @@ export function mergeRequirementVerificationEvidence(existing = [], incoming = [
|
|
|
66
66
|
}
|
|
67
67
|
return [...rows.values()];
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Translate Manager-owned observations into explicitly requirement-scoped
|
|
72
|
+
* checks. Merely seeing files or successfully starting a preview is useful
|
|
73
|
+
* evidence, but it is not proof that the requested behavior is correct.
|
|
74
|
+
*/
|
|
75
|
+
export function buildManagerVerificationChecks({
|
|
76
|
+
requirementIds = [], testResult = null, testsFailing = false, proof = null,
|
|
77
|
+
run = null, previewVerification = null, changedFiles = [],
|
|
78
|
+
} = {}) {
|
|
79
|
+
const ids = cleanList(requirementIds);
|
|
80
|
+
const files = cleanList(changedFiles);
|
|
81
|
+
const checks = [];
|
|
82
|
+
|
|
83
|
+
if (files.length) {
|
|
84
|
+
checks.push({
|
|
85
|
+
kind: 'changed-files', status: 'observed', requirementIds: ids, files,
|
|
86
|
+
detail: `${files.length} changed file${files.length === 1 ? '' : 's'} observed`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (testResult) {
|
|
91
|
+
let status = 'observed';
|
|
92
|
+
if (!testResult.ran || testResult.skipped) status = 'not-applicable';
|
|
93
|
+
else if (testsFailing) status = 'failed';
|
|
94
|
+
else if (testResult.ok === true && !testResult.inconclusive && !testResult.infraOnly && !testResult.preExistingOnly) status = 'verified';
|
|
95
|
+
checks.push({
|
|
96
|
+
kind: 'test', status, requirementIds: ids,
|
|
97
|
+
detail: testResult.detail || testResult.skippedReason || `${testResult.passed ?? 0} passed, ${testResult.failed ?? 0} failed`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (proof) {
|
|
102
|
+
checks.push({
|
|
103
|
+
kind: 'proof',
|
|
104
|
+
status: proof.verified === true ? (proof.pass === true ? 'verified' : 'failed') : 'observed',
|
|
105
|
+
requirementIds: ids,
|
|
106
|
+
detail: proof.detail || 'Proof artifact observed',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (run || previewVerification) {
|
|
111
|
+
checks.push({
|
|
112
|
+
kind: 'open-it', status: 'observed', requirementIds: ids,
|
|
113
|
+
detail: previewVerification?.detail || (previewVerification?.status === 'started'
|
|
114
|
+
? 'Local preview started; visual correctness still requires review'
|
|
115
|
+
: 'Worker declared a local preview'),
|
|
116
|
+
...(previewVerification?.url || run?.url ? { url: previewVerification?.url || run.url } : {}),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return checks;
|
|
121
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -23,7 +23,9 @@ import { pathToFileURL } from 'node:url';
|
|
|
23
23
|
import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, parseRequestedWorkspaceDir } from './lib.mjs';
|
|
24
24
|
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, 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, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
|
|
25
25
|
import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
|
|
26
|
+
import { buildManagerVerificationChecks, buildRequirementVerificationEvidence, mergeRequirementVerificationEvidence } from './requirement-verification.mjs';
|
|
26
27
|
import { createSerialQueue } from './lib.mjs';
|
|
28
|
+
import { wantsPullRequest } from './lib.mjs';
|
|
27
29
|
import { parseRequirementEvidence, unmappedChangedFiles, previewOpensSomethingElse } from './lib.mjs';
|
|
28
30
|
import { isPrClosed } from './lib.mjs';
|
|
29
31
|
import { collectProjectRules } from './lib.mjs';
|
|
@@ -2446,7 +2448,7 @@ function createSplitGoal(parent, text, identity, targetProjectId = parent.projec
|
|
|
2446
2448
|
if (!targetProject) throw new Error(`unknown target project: ${targetProjectId}`);
|
|
2447
2449
|
const goal = {
|
|
2448
2450
|
id: nextId++, projectId: targetProjectId, text: text.slice(0, 8000), status: 'planning',
|
|
2449
|
-
wantsPR: resolveWantsPR('auto', text, getReviewDefinition(targetProjectId).defaultWantsPR),
|
|
2451
|
+
wantsPR: resolveWantsPR('auto', text, getReviewDefinition(targetProjectId).defaultWantsPR, !!targetProject.reviewPrefAsked),
|
|
2450
2452
|
model: parent.model, effort: parent.effort, agent: parent.agent, mode: 'auto',
|
|
2451
2453
|
skill: null, images: [], source: resolveGoalSource('app'), sourceRef: null,
|
|
2452
2454
|
executionPlan: buildExecutionPlan({ projectId: targetProjectId, text, previousSession: false }),
|
|
@@ -3854,6 +3856,26 @@ function prSafetyIssue(goal, project, files) {
|
|
|
3854
3856
|
return classifyPrSafety({ baseline, files, currentFiles: gitStatusPaths(lines), currentBranch });
|
|
3855
3857
|
}
|
|
3856
3858
|
|
|
3859
|
+
function syncGoalRequirementVerification(goal, siblings, { testsFailing = false } = {}) {
|
|
3860
|
+
const incoming = siblings.flatMap((task) => buildRequirementVerificationEvidence({
|
|
3861
|
+
requirementIds: task.activeRequirementIds ?? [],
|
|
3862
|
+
attemptId: task.attemptId || `task-${task.id}`,
|
|
3863
|
+
taskId: task.id,
|
|
3864
|
+
workerEvidence: task.requirementEvidence,
|
|
3865
|
+
changedFiles: task.changedFiles ?? [],
|
|
3866
|
+
checks: buildManagerVerificationChecks({
|
|
3867
|
+
requirementIds: task.activeRequirementIds ?? [],
|
|
3868
|
+
testResult: goal.testResult,
|
|
3869
|
+
testsFailing,
|
|
3870
|
+
proof: task.proof,
|
|
3871
|
+
run: task.run,
|
|
3872
|
+
previewVerification: task.previewVerification,
|
|
3873
|
+
changedFiles: task.changedFiles ?? [],
|
|
3874
|
+
}),
|
|
3875
|
+
}));
|
|
3876
|
+
goal.requirementEvidence = mergeRequirementVerificationEvidence(goal.requirementEvidence ?? [], incoming);
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3857
3879
|
// The Manager-verified review gate (shared by goal completion and /reverify):
|
|
3858
3880
|
// run OUR OWN tests, generate a plain-language headline, and require proof for a
|
|
3859
3881
|
// UI change — capturing it (3 tries) if missing. Any failure → 'blocked' (shown
|
|
@@ -3975,6 +3997,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3975
3997
|
// Count that drives the block message: the NEW failures when we could diff
|
|
3976
3998
|
// against the base, else the raw total (fail-closed when the base is unknown).
|
|
3977
3999
|
const blockingFailCount = goal.testResult.newFailures?.length ?? goal.testResult.failed;
|
|
4000
|
+
// Persist the Manager's own requirement-scoped observations before any
|
|
4001
|
+
// automatic rework return. A worker claim alone never becomes verified.
|
|
4002
|
+
syncGoalRequirementVerification(goal, siblings, { testsFailing });
|
|
4003
|
+
saveGoal(goal);
|
|
3978
4004
|
// §4補足: requireVerifyPass (opt-in, off by default) makes the gate strict —
|
|
3979
4005
|
// EVERY task must carry a passing proof, not just UI tasks. Only ever tightens;
|
|
3980
4006
|
// never conflicts with §6 (still can't reach Done without a human).
|
|
@@ -4807,7 +4833,7 @@ const server = createServer(async (req, res) => {
|
|
|
4807
4833
|
approvalDecision: null,
|
|
4808
4834
|
reviewOnly: review ? true : undefined,
|
|
4809
4835
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
4810
|
-
wantsPR: (review || approval) ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
4836
|
+
wantsPR: (review || approval) ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR, !!project.reviewPrefAsked),
|
|
4811
4837
|
model: workerModel(goalAgent, model),
|
|
4812
4838
|
effort: workerEffort(goalAgent, effort),
|
|
4813
4839
|
agent: goalAgent,
|
|
@@ -4925,7 +4951,8 @@ const server = createServer(async (req, res) => {
|
|
|
4925
4951
|
if (promote) goal.prio = ++prioCounter;
|
|
4926
4952
|
if (text?.trim()) {
|
|
4927
4953
|
goal.text = text.trim().slice(0, 8000);
|
|
4928
|
-
|
|
4954
|
+
const project = projects.find((p) => p.id === goal.projectId);
|
|
4955
|
+
goal.wantsPR = resolveWantsPR(pr, goal.text, getReviewDefinition(goal.projectId).defaultWantsPR, !!project?.reviewPrefAsked);
|
|
4929
4956
|
}
|
|
4930
4957
|
saveGoal(goal);
|
|
4931
4958
|
json(res, 200, goal);
|
|
@@ -5036,14 +5063,27 @@ const server = createServer(async (req, res) => {
|
|
|
5036
5063
|
const goal = goals.find((g) => g.id === task.goalId);
|
|
5037
5064
|
startPreview(task)
|
|
5038
5065
|
.then((p) => {
|
|
5066
|
+
task.previewVerification = {
|
|
5067
|
+
status: 'started', url: p.url, at: new Date().toISOString(),
|
|
5068
|
+
detail: 'Local preview started; visual correctness still requires review',
|
|
5069
|
+
};
|
|
5070
|
+
saveTask(task);
|
|
5039
5071
|
if (goal?.verificationInfraError?.source === 'preview') {
|
|
5040
5072
|
goal.verificationInfraError = null;
|
|
5073
|
+
}
|
|
5074
|
+
if (goal) {
|
|
5075
|
+
syncGoalRequirementVerification(goal, tasks.filter((item) => item.goalId === goal.id), {
|
|
5076
|
+
testsFailing: Boolean(goal.testResult?.ran && goal.testResult.failed > 0
|
|
5077
|
+
&& !goal.testResult.inconclusive && !goal.testResult.infraOnly && !goal.testResult.preExistingOnly),
|
|
5078
|
+
});
|
|
5041
5079
|
saveGoal(goal);
|
|
5042
5080
|
}
|
|
5043
5081
|
json(res, 200, p);
|
|
5044
5082
|
})
|
|
5045
5083
|
.catch((e) => {
|
|
5046
5084
|
const detail = String(e.message ?? e).slice(0, 200);
|
|
5085
|
+
task.previewVerification = { status: 'infra-error', detail, at: new Date().toISOString() };
|
|
5086
|
+
saveTask(task);
|
|
5047
5087
|
if (goal && goal.status === 'review') {
|
|
5048
5088
|
goal.verificationInfraError = buildVerificationInfraError({
|
|
5049
5089
|
source: 'preview',
|
|
@@ -5483,7 +5523,7 @@ const server = createServer(async (req, res) => {
|
|
|
5483
5523
|
send({ ev: 'review-definition', projectId: project.id, reviewDefinition: merged.definition });
|
|
5484
5524
|
}
|
|
5485
5525
|
}
|
|
5486
|
-
goal.wantsPR = resolveWantsPR(undefined, goal.text, patch.defaultWantsPR);
|
|
5526
|
+
goal.wantsPR = resolveWantsPR(undefined, goal.text, patch.defaultWantsPR, true);
|
|
5487
5527
|
goal.question = null;
|
|
5488
5528
|
goal.clarified = true;
|
|
5489
5529
|
goal.status = 'stacked';
|
|
@@ -5549,7 +5589,7 @@ const server = createServer(async (req, res) => {
|
|
|
5549
5589
|
// both asked twice and got no PR either time.
|
|
5550
5590
|
// Upgrade only: this can turn the deliverable on, never off, so a later
|
|
5551
5591
|
// unrelated reply cannot silently withdraw a PR the person already asked for.
|
|
5552
|
-
if (
|
|
5592
|
+
if (wantsPullRequest(text) && goal.wantsPR !== true) {
|
|
5553
5593
|
goal.wantsPR = true;
|
|
5554
5594
|
saveGoal(goal);
|
|
5555
5595
|
}
|
|
@@ -5958,7 +5998,7 @@ async function answerGoalQuestion(goal, question) {
|
|
|
5958
5998
|
// (rescope) or while asking a question (answer) is still a PR they asked
|
|
5959
5999
|
// for, and it must survive until close-out rather than being dropped
|
|
5960
6000
|
// because this particular reply happened not to wake a worker.
|
|
5961
|
-
if (
|
|
6001
|
+
if (wantsPullRequest(text)) goal.wantsPR = true;
|
|
5962
6002
|
goal.status = result.status;
|
|
5963
6003
|
saveGoal(goal);
|
|
5964
6004
|
// rescope はワーカーを起こさない——「いったん止めて考え直す」なので、走り出したら
|
package/package.json
CHANGED