@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 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:flex-start;gap:5px;flex:0 0 auto}
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
- const running = pgoals.some((g) => fsBoardBucket(g, ptasks) === 'doing'
6536
- && !(g.status === 'blocked' && g.blocked?.kind === 'paused'));
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
- try { localStorage.setItem('draft', $('input').value ?? ''); } catch {}
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
- // Feature A (ONE-CONVERSATION §1.2/§1.4 "1本の会話"): the composer CONTINUES the
9024
- // goal you're focused on (state.selectedGoal) instead of always making a new goal
9025
- // like Claude Code, where you keep talking to the same session. It binds only
9026
- // when that goal is in the ACTIVE project AND in a repliable status: a review goal
9027
- // routes to the AI-decide reply (continue/rescope/split/answer), an in-flight or
9028
- // finished-rework goal takes a plain follow-up. Anything else (done, needsInput,
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
- const id = state.selectedGoal;
9034
- if (id == null) return null;
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, matching the
2158
- // pre-task-45 behavior that new callers who omit this argument keep exactly.
2159
- export function resolveWantsPR(pr, text, projectDefault = false) {
2160
- return pr === 'pr' ? true : pr === 'none' ? false : (wantsPullRequest(text) || !!projectDefault);
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 : 'not-addressed';
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: cleanText(value.evidence),
75
- files: [...new Set(Array.isArray(value.files) ? value.files.filter((item) => typeof item === 'string' && item) : [])],
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
- goal.wantsPR = resolveWantsPR(pr, goal.text, getReviewDefinition(goal.projectId).defaultWantsPR);
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 (resolveWantsPR('auto', text, false) && goal.wantsPR !== true) {
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 (resolveWantsPR('auto', text, false)) goal.wantsPR = true;
6001
+ if (wantsPullRequest(text)) goal.wantsPR = true;
5962
6002
  goal.status = result.status;
5963
6003
  saveGoal(goal);
5964
6004
  // rescope はワーカーを起こさない——「いったん止めて考え直す」なので、走り出したら
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.98",
3
+ "version": "0.10.100",
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": {