@galda/cli 0.10.96 → 0.10.97

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
@@ -1920,6 +1920,11 @@
1920
1920
  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
1921
  .sa-app:hover{box-shadow:inset 0 0 0 1px var(--green);background:rgba(var(--green-rgb),.12)}
1922
1922
  .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}
1924
+ .sa-approve-wrap + .sa-dis{align-self:flex-start}
1925
+ .sa-delivery{font:500 9.5px/1.15 var(--mono);color:var(--ink3);text-decoration:none;white-space:nowrap}
1926
+ a.sa-delivery{color:var(--green)}
1927
+ a.sa-delivery:hover{text-decoration:underline}
1923
1928
  .sa-dis{height:32px;padding:0 13px;border:0;border-radius:9px;background:color-mix(in srgb,var(--danger) 8%,var(--panel));
1924
1929
  color:color-mix(in srgb,var(--danger) 70%,var(--ink));box-shadow:inset 0 0 0 1px rgba(var(--danger-rgb),.25);font:500 12px/1 var(--ui);cursor:pointer}
1925
1930
  .sa-dis:hover{box-shadow:inset 0 0 0 1px var(--danger);background:color-mix(in srgb,var(--danger) 8%,var(--panel))}
@@ -2208,6 +2213,30 @@
2208
2213
  .redesign .reqbody .lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
2209
2214
  .redesign .reqbody .lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:12px;line-height:1.8;color:var(--ink3)}
2210
2215
  .redesign .reqbody .lg-splitnote li.cur{color:var(--ink)} .redesign .reqbody .lg-splitnote li.cur b{color:var(--green);font-weight:600}
2216
+ /* Request stack (2026-07-29, Masa: "This reviewをやめてRequestに2発目・3発目として
2217
+ 積み上げる"): every ask on this goal as a numbered list, most recent emphasised.
2218
+ Replaces the single Request paragraph + separate "This review" section. */
2219
+ .redesign .rqlist{display:flex;flex-direction:column;gap:12px;margin-top:2px}
2220
+ .redesign .rqit{display:flex;gap:10px;align-items:baseline}
2221
+ .redesign .rqit .rqn{flex:0 0 16px;font:600 12px/1.5 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
2222
+ .redesign .rqit .rqbody{flex:1;min-width:0}
2223
+ /* §2 forces .rask to full-contrast ink !important everywhere else in the card (it's
2224
+ the user's own words, never faded) — the Request stack is the one place that rule
2225
+ is deliberately overridden: only the LATEST ask reads at full weight. */
2226
+ .redesign .rqit .rqbody .rbody.rask{color:var(--ink2) !important}
2227
+ .redesign .rqit.latest .rqn{color:var(--blue,#7C9EFF)}
2228
+ .redesign .rqit.latest .rqbody .rbody.rask{color:var(--ink) !important;font-weight:600}
2229
+ /* Changed/Tests aligns with the request TEXT, not the number/chevron — same 18px
2230
+ the fold body below uses, so both sit under the same column (Masa 2026-07-29). */
2231
+ .redesign .rqit .rqev{font-size:12px;color:var(--ink3);margin:5px 0 0;padding-left:18px}
2232
+ /* A long ask folds with the SAME .lacc pulldown Activity/Conversation use — chevron on
2233
+ the peek line, body is the genuine rest (no duplicate, no trailing "…": Masa
2234
+ 2026-07-29 flagged both the vertical anchor line and the "…" as unnecessary — the
2235
+ chevron plus the aligned indent already say "there's more" on their own). */
2236
+ .redesign .rqit .rqfold{margin-top:0}
2237
+ .redesign .rqit .rqfold>summary{gap:9px}
2238
+ .redesign .rqit .rqfold .lbody{margin-top:4px;padding-left:18px}
2239
+ .redesign .rqit.latest .rqfold .lbody .rbody.rask{color:var(--ink) !important}
2211
2240
  /* workflow (spec §8) — vertical ✓ green / → green / □ dim */
2212
2241
  .redesign .rwf{display:flex;flex-direction:column;gap:2px;margin:10px 0 0 !important;font-family:var(--ui);font-size:12px;letter-spacing:-.004em;color:var(--ink3)}
2213
2242
  .redesign .rwf .wf-s{display:flex;align-items:center;gap:9px}
@@ -9772,6 +9801,30 @@ function saWorkflowHtml(it){
9772
9801
  const steps = [['Implemented', atImplemented ? 'now' : 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', atImplemented ? 'todo' : 'now'], ['Merge', 'todo']];
9773
9802
  return '<div class="rwf">' + steps.map(([s, st]) => `<div class="wf-s ${st}"><span class="mk">${st === 'ok' ? '✓' : st === 'now' ? '→' : '□'}</span>${s}</div>`).join('') + '</div>';
9774
9803
  }
9804
+ // Request stack rows (2026-07-29): shared by saItemHtml's first paint and
9805
+ // saLoadMessages's patch once the conversation fetch lands (see its call site) —
9806
+ // one function so the fold/no-duplicate rule stays identical in both places.
9807
+ function saReqStackRows(it, attemptEvidence){
9808
+ const gk = it.goalId;
9809
+ const reqMsgs = SA.msgs[gk];
9810
+ const reqAsks = Array.isArray(reqMsgs) ? reqMsgs.filter((m) => m?.from === 'you' && String(m.text || '').trim()) : [];
9811
+ const reqItems = reqAsks.length ? reqAsks : (it.goalText ? [{ text: it.goalText }] : []);
9812
+ return reqItems.map((m, idx) => {
9813
+ const text = String(m.text || '').trim();
9814
+ const isLatest = idx === reqItems.length - 1;
9815
+ const evLine = isLatest && attemptEvidence ? `<p class="rqev">${esc(attemptEvidence)}</p>` : '';
9816
+ const body = shouldFoldRequestText(text)
9817
+ ? (() => {
9818
+ const peek = text.slice(0, REQUEST_PEEK_CHARS).trimEnd();
9819
+ const rest = text.slice(peek.length).trimStart();
9820
+ const key = `${gk}:req${idx}`;
9821
+ const open = SA.detOpen.has(key);
9822
+ return `<details class="lacc rqfold" data-detkey="${key}"${open ? ' open' : ''}><summary><i class="ltog">›</i><p class="rbody rask">${esc(peek)}</p></summary><div class="lbody"><p class="rbody rask">${esc(rest)}</p></div></details>`;
9823
+ })()
9824
+ : `<p class="rbody rask">${esc(text)}</p>`;
9825
+ return `<div class="rqit${isLatest ? ' latest' : ''}"><span class="rqn">${idx + 1}</span><div class="rqbody">${body}${evLine}</div></div>`;
9826
+ }).join('');
9827
+ }
9775
9828
  function saItemHtml(it, i){
9776
9829
  // st===3 is NOT a terminal row (REVIEW-FEEDBACK-FLOW 決定3): a reply is not a dismissal.
9777
9830
  // The old sa-done "feedback returned to To Do" line is gone; st===3 now renders the full
@@ -9860,22 +9913,11 @@ function saItemHtml(it, i){
9860
9913
  // no left rule) — not a bespoke widget (Masa 2026-07-27: "他の›と同じルールじゃなくない?").
9861
9914
  // §4 assumed Request was always short, which a mispaste can break; this reuses the one
9862
9915
  // pulldown shape the card already has rather than inventing a second one. ──
9863
- const reqFold = it.goalText && shouldFoldRequestText(it.goalText);
9864
- const reqOpen = reqFold && SA.detOpen.has(`${gk}:req`);
9865
- const reqPeek = reqFold ? `${it.goalText.slice(0, REQUEST_PEEK_CHARS).trimEnd()}…` : '';
9866
- const reqSec = !it.goalText ? '' : reqFold
9867
- ? `<section class="rsec rreq"><div class="rsec-h">Request</div><details class="lacc" data-detkey="${gk}:req"${reqOpen ? ' open' : ''}><summary><i class="ltog">›</i><p class="rbody rask lfirst">${esc(reqPeek)}</p></summary><div class="lbody"><p class="rbody rask">${esc(it.goalText)}</p></div></details></section>`
9868
- : `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`;
9869
9916
  // The active attempt is intentionally a compact fact row, not a second
9870
9917
  // interpretation of the request. It makes the latest reply, files and
9871
9918
  // verification visible together while prior attempts stay in Conversation.
9872
9919
  const attempt = it.attempt;
9873
- const activeText = String(attempt?.activeRequirement?.text || '').replace(/\s+/g, ' ').trim();
9874
9920
  const requestText = String(it.goalText || '').replace(/\s+/g, ' ').trim();
9875
- // A single requirement is commonly recorded verbatim from the Request. Do
9876
- // not make the review read it back as a second section; keep this section for
9877
- // a narrowed retry requirement or concrete run evidence instead.
9878
- const reviewText = activeText && activeText !== requestText ? activeText : '';
9879
9921
  const attemptFiles = Array.isArray(attempt?.changedFiles) ? attempt.changedFiles.filter(Boolean) : [];
9880
9922
  const attemptTests = attempt?.verification?.tests;
9881
9923
  const attemptProof = attempt?.verification?.proof;
@@ -9884,8 +9926,23 @@ function saItemHtml(it, i){
9884
9926
  attemptTests?.ran ? `Tests: ${attemptTests.passed ?? 0} passed${attemptTests.failed ? ` / ${attemptTests.failed} failed` : ''}` : '',
9885
9927
  attemptProof?.pass === true ? 'Proof recorded' : '',
9886
9928
  ].filter(Boolean).join(' · ');
9887
- const attemptSec = reviewText || attemptEvidence
9888
- ? `<section class="rsec"><div class="rsec-h">This review</div>${reviewText ? `<p class="rbody rask">${esc(reviewText)}</p>` : ''}${attemptEvidence ? `<p class="rbody v">${esc(attemptEvidence)}</p>` : ''}</section>`
9929
+ // Request (redesigned 2026-07-29, Masa: "This reviewをやめてRequestに2発目・3発目
9930
+ // として積み上げる"): every ask on this goal, numbered, latest emphasised not just
9931
+ // the original text plus a separate "This review" summary of only the newest one.
9932
+ // Reuses the same Conversation messages (SA.msgs) that section already fetches;
9933
+ // fetched proactively at render time (see the saLoadMessages call in the render-patch
9934
+ // loop) so the stack has more than #1 without waiting for the reviewer to open
9935
+ // Conversation first. it.goalText alone is the honest fallback while that fetch is
9936
+ // still in flight or the goal has no replies yet. A long ask folds using the SAME
9937
+ // `.lacc` pulldown as Activity/Conversation (spec §3: chevron on the first line, the
9938
+ // body is the genuine REST of the text — not the peek repeated in full below it, which
9939
+ // is what the pre-2026-07-29 version did and Masa flagged as confusing: "点々のあとに
9940
+ // そのまま文から続くのって、ちょっとわかりにくい"). No trailing "…" either — the
9941
+ // chevron alone says there's more. Row logic lives in saReqStackRows so the first
9942
+ // paint here and the async patch in saLoadMessages stay identical.
9943
+ const reqStackRows = saReqStackRows(it, attemptEvidence);
9944
+ const reqSec = reqStackRows
9945
+ ? `<section class="rsec rreq" data-reqmsgs="${gk}"><div class="rsec-h">Request</div><div class="rqlist">${reqStackRows}</div></section>`
9889
9946
  : '';
9890
9947
  const reqVerify = it.requirementVerification;
9891
9948
  const reqVerifyParts = [
@@ -10135,20 +10192,23 @@ function saItemHtml(it, i){
10135
10192
  : attn
10136
10193
  ? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
10137
10194
  : '<span class="rst"><span class="rdot"></span>In review</span>';
10195
+ const prNumber = (it.pr?.match(/\/pull\/(\d+)/) ?? [, ''])[1];
10196
+ const delivery = it.pr
10197
+ ? `<a class="sa-delivery" href="${esc(it.pr)}" target="_blank" rel="noopener">PR #${esc(prNumber)} ↗</a>`
10198
+ : '<span class="sa-delivery">No pull request</span>';
10138
10199
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}${working ? ' working' : ''}" data-gid="${it.goalId}">
10139
10200
  <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span>${rst}</div>
10140
10201
  <h2 class="rtitle">${esc(it.title)}</h2>
10141
10202
  ${meta}
10142
10203
  ${reqSec}
10143
- ${attemptSec}
10204
+ ${S('Open it', openBody + infraBody)}
10205
+ ${S('What to check', checkBody)}
10144
10206
  ${coverageSec}
10145
10207
  ${evidenceSec}
10146
10208
  ${reqVerifySec}
10147
- ${S('Open it', openBody + infraBody)}
10148
10209
  ${S('Run state', interruptedBody)}
10149
10210
  ${S('PR', prRepairBody)}
10150
10211
  ${S('Also carries', carriesBody)}
10151
- ${S('What to check', checkBody)}
10152
10212
  ${S('Result', reportBody)}
10153
10213
  ${S('Screenshots', shotsBody)}
10154
10214
  ${S('Steps', stepsBody)}
@@ -10159,7 +10219,7 @@ function saItemHtml(it, i){
10159
10219
  ${conversationSec}
10160
10220
  ${activitySec}
10161
10221
  ${S('Workflow', saWorkflowHtml(it))}
10162
- <div class="sa-act">${attn ? '' : `<button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-replybox"><div class="sa-replyattach${SA.attachments[it.goalId]?.length ? ' has' : ''}" data-saattach="${it.goalId}">${saReplyAttachmentsHtml(it.goalId)}</div><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or paste an image…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
10222
+ <div class="sa-act">${attn ? '' : `<div class="sa-approve-wrap"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button>${delivery}</div><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-replybox"><div class="sa-replyattach${SA.attachments[it.goalId]?.length ? ' has' : ''}" data-saattach="${it.goalId}">${saReplyAttachmentsHtml(it.goalId)}</div><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or paste an image…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
10163
10223
  </div>`;
10164
10224
  }
10165
10225
  // Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
@@ -10203,10 +10263,36 @@ async function saLoadMessages(goalId){
10203
10263
  } catch { msgs = []; }
10204
10264
  SA.msgs[goalId] = msgs;
10205
10265
  const body = document.querySelector(`#seeall [data-msgbody="${goalId}"]`);
10206
- if (!body) return;
10207
- body.innerHTML = msgs.length
10208
- ? saMsgRowsHtml(msgs, SA.convOpen.has(goalId))
10209
- : '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>';
10266
+ if (body) {
10267
+ body.innerHTML = msgs.length
10268
+ ? saMsgRowsHtml(msgs, SA.convOpen.has(goalId))
10269
+ : '<div class="txr res"><span class="w"></span><span class="m">Nothing was said on this one.</span></div>';
10270
+ }
10271
+ // The Request stack (2026-07-29) reads the same fetch — patch it too, in place, so
10272
+ // #2/#3 appear without the card re-rendering (which would close every open pulldown).
10273
+ const reqList = document.querySelector(`#seeall [data-reqmsgs="${goalId}"] .rqlist`);
10274
+ if (reqList) {
10275
+ const it = SA.items.find((x) => x.goalId === goalId);
10276
+ if (it) {
10277
+ const attempt = it.attempt;
10278
+ const attemptFiles = Array.isArray(attempt?.changedFiles) ? attempt.changedFiles.filter(Boolean) : [];
10279
+ const attemptTests = attempt?.verification?.tests;
10280
+ const attemptProof = attempt?.verification?.proof;
10281
+ const attemptEvidence = [
10282
+ attemptFiles.length ? `Changed: ${attemptFiles.join(' · ')}` : '',
10283
+ attemptTests?.ran ? `Tests: ${attemptTests.passed ?? 0} passed${attemptTests.failed ? ` / ${attemptTests.failed} failed` : ''}` : '',
10284
+ attemptProof?.pass === true ? 'Proof recorded' : '',
10285
+ ].filter(Boolean).join(' · ');
10286
+ reqList.innerHTML = saReqStackRows(it, attemptEvidence);
10287
+ // Newly patched nodes miss the [data-detkey] wiring the main render loop applies —
10288
+ // re-attach it for just this list (no actlog/msgs on a request fold, so nothing else needed).
10289
+ for (const det of reqList.querySelectorAll('[data-detkey]')) {
10290
+ det.addEventListener('toggle', () => {
10291
+ if (det.open) SA.detOpen.add(det.dataset.detkey); else SA.detOpen.delete(det.dataset.detkey);
10292
+ });
10293
+ }
10294
+ }
10295
+ }
10210
10296
  }
10211
10297
  // After first paint, compute the red box for any card showing a before/after pair and
10212
10298
  // patch that card's node directly — a full re-render here would close every open
@@ -10282,6 +10368,12 @@ function saRender(){
10282
10368
  if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
10283
10369
  if (det.open && det.dataset.msgs) saLoadMessages(Number(det.dataset.msgs));
10284
10370
  }
10371
+ // The Request stack (2026-07-29) needs the same conversation messages Conversation
10372
+ // fetches, but the reviewer must not have to open Conversation first just to see
10373
+ // "#2, #3…" — so this one fetch is unconditional, not gated on an accordion being
10374
+ // open. saLoadMessages is already idempotent per goal (SA.msgs cache), so a card
10375
+ // whose Conversation is also open only asks the server once.
10376
+ for (const rq of S.querySelectorAll('[data-reqmsgs]')) saLoadMessages(Number(rq.dataset.reqmsgs));
10285
10377
  // "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
10286
10378
  // show a broken-image placeholder. proofImg is only a stored filename, so a file that
10287
10379
  // isn't on disk (or fails to load) can only be caught at load time — drop the whole
@@ -1,27 +1,93 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
2
3
 
3
4
  const MAC_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
4
5
 
6
+ // The human's Chrome session — the only one that can put a "quit unexpectedly"
7
+ // dialog on their screen — always runs out of this profile. Every automated
8
+ // launch (ours, or puppeteer's own default) passes an explicit --user-data-dir
9
+ // somewhere else. Measured 2026-07-29: on a headless launch the browser process
10
+ // AND all twelve helpers carry that flag, so one line is enough to tell whose
11
+ // Chrome it is.
12
+ const defaultProfileDir = () => `${homedir()}/Library/Application Support/Google/Chrome`;
13
+
14
+ const readVersion = () => execFileSync('/usr/libexec/PlistBuddy', [
15
+ '-c', 'Print:CFBundleShortVersionString',
16
+ '/Applications/Google Chrome.app/Contents/Info.plist',
17
+ ], { encoding: 'utf8' }).trim();
18
+
19
+ const readPs = () => execFileSync('/bin/ps', ['-axo', 'command='], { encoding: 'utf8' });
20
+
5
21
  // Chrome updates its app bundle while an older copy can remain open. Launching
6
22
  // the newly installed executable during that window aborts inside Chrome and
7
23
  // macOS shows a scary "quit unexpectedly" dialog.
24
+ //
25
+ // Only the human's own session counts. A leaked headless Chrome from a test run
26
+ // twenty hours ago is a stale version too, and when it was allowed to vote it
27
+ // silenced every browser check on the machine for as long as it lived — the
28
+ // screenshot the worker was asked for became "no Chrome on this machine", and
29
+ // the worker went off and wrote a static HTML file instead (goals #871 #835
30
+ // #877). A background process we started cannot show anyone a dialog, so it has
31
+ // no business deferring anyone's verification.
8
32
  export function chromeUpdatePending(chromePath, {
9
33
  platform = process.platform,
10
- readCurrentVersion = () => execFileSync('/usr/libexec/PlistBuddy', [
11
- '-c', 'Print:CFBundleShortVersionString',
12
- '/Applications/Google Chrome.app/Contents/Info.plist',
13
- ], { encoding: 'utf8' }).trim(),
14
- readProcesses = () => execFileSync('/bin/ps', ['-axo', 'command='], { encoding: 'utf8' }),
34
+ readCurrentVersion = readVersion,
35
+ readProcesses = readPs,
36
+ humanProfileDir = defaultProfileDir,
15
37
  } = {}) {
16
- if (platform !== 'darwin' || chromePath !== MAC_CHROME) return false;
38
+ return pendingVersions(chromePath, { platform, readCurrentVersion, readProcesses, humanProfileDir }).length > 0;
39
+ }
40
+
41
+ function pendingVersions(chromePath, { platform, readCurrentVersion, readProcesses, humanProfileDir }) {
42
+ if (platform !== 'darwin' || chromePath !== MAC_CHROME) return [];
17
43
  try {
18
44
  const current = readCurrentVersion();
19
- const running = [...readProcesses().matchAll(/Google Chrome Framework\.framework\/Versions\/([^/\s]+)/g)]
20
- .map((match) => match[1]);
21
- return running.some((version) => version !== current);
45
+ const profile = humanProfileDir();
46
+ const stale = new Set();
47
+ for (const line of readProcesses().split('\n')) {
48
+ const version = line.match(/Google Chrome Framework\.framework\/Versions\/([^/\s]+)/)?.[1];
49
+ if (!version || version === current) continue;
50
+ // An explicit profile that is not the human's = something automated.
51
+ // Matched by substring because the real path contains a space, which the
52
+ // `ps` output gives us no way to delimit.
53
+ if (line.includes('--user-data-dir=') && !line.includes(profile)) continue;
54
+ stale.add(version);
55
+ }
56
+ return [...stale];
22
57
  } catch {
23
- return false;
58
+ return [];
59
+ }
60
+ }
61
+
62
+ // Why a browser is or is not available, in the caller's words.
63
+ //
64
+ // "Chrome is missing" and "Chrome is here but we are standing down for a
65
+ // moment" are different facts with different fixes, and collapsing them into a
66
+ // single null taught everything downstream to say the wrong one. Callers that
67
+ // only need a path still get `path`; callers that report to a human get
68
+ // `reason` and a sentence that names the actual remedy.
69
+ export function chromeStatus(chromePath, options = {}) {
70
+ if (!chromePath) {
71
+ return {
72
+ path: null,
73
+ reason: 'missing',
74
+ detail: 'Chrome is not installed here — install Google Chrome or set CHROME_PATH to the binary.',
75
+ };
76
+ }
77
+ const stale = pendingVersions(chromePath, {
78
+ platform: options.platform ?? process.platform,
79
+ readCurrentVersion: options.readCurrentVersion ?? readVersion,
80
+ readProcesses: options.readProcesses ?? readPs,
81
+ humanProfileDir: options.humanProfileDir ?? defaultProfileDir,
82
+ });
83
+ if (stale.length) {
84
+ return {
85
+ path: null,
86
+ reason: 'update-pending',
87
+ detail: `Chrome updated while it was open (still running ${stale.join(', ')}). Quit and reopen Chrome, then retry — Chrome IS installed on this machine.`,
88
+ };
24
89
  }
90
+ return { path: chromePath, reason: 'ok', detail: '' };
25
91
  }
26
92
 
27
93
  // Browser tests use this instead of launching the first installed binary
@@ -29,6 +95,5 @@ export function chromeUpdatePending(chromePath, {
29
95
  // behavior, so a Chrome update never becomes a product failure or a macOS
30
96
  // crash dialog.
31
97
  export function safeChromePath(chromePath, options) {
32
- if (!chromePath) return null;
33
- return chromeUpdatePending(chromePath, options) ? null : chromePath;
98
+ return chromeStatus(chromePath, options).path;
34
99
  }
@@ -40,10 +40,13 @@ export const COMPLETION_CONTRACT_PROMPT = [
40
40
  '- The latest explicit user instruction can revise an older product decision or code comment. Treat old comments as context, not proof that the requested change is impossible. If the change has consequences, explain them and implement the latest request; only a real safety, permission, legal, or external-system constraint is a blocker.',
41
41
  '- A test must assert the observable outcome the user requested. Never replace the request with a tooltip, explanation, or a test that permanently preserves the opposite behavior.',
42
42
  '- Do not report a change request as complete with “cannot fix”, “直せません”, or equivalent. If a real external blocker remains, state the concrete blocker and leave the work explicitly unfinished instead of presenting it for Review as completed.',
43
- '- You own the routing decision. When the run is not complete, append exactly one single-line receipt: MANAGER_OUTCOME: {"outcome":"needs-input|blocked|rebind-workspace","summary":"short reason","question":"question for the user when needed","options":["optional choice"],"workingDirectory":"absolute path for rebind-workspace"}. Do not emit this receipt for completed work. Galda transports this declaration verbatim; it does not reinterpret your prose.',
43
+ '- Git commit, push, and PR creation belong to Galda close-out, not the worker. Never call that division of responsibility a permission blocker and never ask the user to run gh or git for you.',
44
+ '- Never infer that a commit bypassed Pull Requests from its subject, parent count, or the absence of “Merge pull request” in a narrow git log. Squash/rebase/merge strategies produce different commit shapes. Claim “no PR” only after verifying commit-to-PR association through GitHub; otherwise say PR provenance is unknown.',
45
+ '- If the requested change is already present in origin/main and there is no branch diff to put in a PR, that is not blocked. Verify the exact commit/result and append: MANAGER_OUTCOME: {"outcome":"already-satisfied","summary":"what is already present and where"}. A PR cannot be retroactively wrapped around a commit already on main; report the verification honestly and finish normally.',
46
+ '- You own the routing decision. When the run needs routing, append exactly one single-line receipt: MANAGER_OUTCOME: {"outcome":"needs-input|blocked|rebind-workspace|already-satisfied","summary":"short reason","question":"question for the user when needed","options":["optional choice"],"workingDirectory":"absolute path for rebind-workspace"}. Do not emit this receipt for an ordinary completed change. Galda transports this declaration verbatim; it does not reinterpret your prose.',
44
47
  ].join('\n');
45
48
 
46
- const MANAGER_OUTCOMES = new Set(['needs-input', 'blocked', 'rebind-workspace']);
49
+ const MANAGER_OUTCOMES = new Set(['needs-input', 'blocked', 'rebind-workspace', 'already-satisfied']);
47
50
 
48
51
  // A transport receipt written by the worker, not a Manager inference. Keep the
49
52
  // schema deliberately tiny: the agent decides what happened; Galda only checks
@@ -62,6 +65,7 @@ export function parseManagerOutcome(raw) {
62
65
  const workingDirectory = typeof value.workingDirectory === 'string' ? value.workingDirectory.trim().slice(0, 1000) : '';
63
66
  if (value.outcome === 'needs-input' && !question) return null;
64
67
  if (value.outcome === 'blocked' && !summary) return null;
68
+ if (value.outcome === 'already-satisfied' && !summary) return null;
65
69
  if (value.outcome === 'rebind-workspace' && !workingDirectory.startsWith('/')) return null;
66
70
  return { outcome: value.outcome, summary, question, options, workingDirectory };
67
71
  }
package/engine/lib.mjs CHANGED
@@ -2480,12 +2480,13 @@ export function dismissGoal({ goalStatus, outcome = 'continue' } = {}) {
2480
2480
  return { ok: true, outcome: 'continue', status: 'running', spawnsWorker: true };
2481
2481
  }
2482
2482
 
2483
- // `outcome:auto` is the contract used by every review reply box in the app.
2484
- // Keep the routing decision in one place: the reply either asks a question,
2485
- // requests a change, rescopes the ticket, starts separate work, or explicitly
2486
- // remains unsure. Returning unsure is safe because the UI already presents
2487
- // these exact choices without moving the goal.
2488
- export const REPLY_INTENTS = [...REPLY_OUTCOMES, 'unsure'];
2483
+ // REPLY_INTENTS lived here REPLY_OUTCOMES plus 'unsure', the vocabulary the
2484
+ // router answered in. #518 removed the router but left this constant exported,
2485
+ // and nothing imported it except one test's own skip guard
2486
+ // (`/REPLY_INTENTS/.test(lib.mjs)`), which therefore kept believing the router
2487
+ // was present and ran five tests for deleted behaviour. `main` was red from
2488
+ // that merge onward. A dead export is not free when something reads the source
2489
+ // to decide whether a feature exists.
2489
2490
  // buildReplyIntentPrompt / parseReplyIntent lived here: the prompt and parser for a
2490
2491
  // utility LLM that decided what a Review reply MEANT before the goal's own agent saw
2491
2492
  // it. They are gone with their caller. Its "answer" bucket — "a question about the
@@ -3254,7 +3255,7 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3254
3255
  runContext ? (typeof runContext === 'string' ? runContext : buildWorkerContextBlock(runContext)) : '',
3255
3256
  '',
3256
3257
  `TASK ${task.num}: ${task.title}`,
3257
- `REQUIREMENT ID FOR THIS RUN: task-${task.id ?? task.num}.`,
3258
+ `REQUIREMENT IDS FOR THIS RUN: ${(task?.activeRequirementIds?.length ? task.activeRequirementIds : [`task-${task.id ?? task.num}`]).join(', ')}.`,
3258
3259
  task.detail ? `DETAIL: ${task.detail}` : '',
3259
3260
  `WORKSPACE MODE: ${workspaceMode}.`,
3260
3261
  goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
@@ -3276,7 +3277,7 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3276
3277
  // instead of expanding scope"): the request, not our fear of scope, sets
3277
3278
  // the size of the work.
3278
3279
  '- Deliver what the request actually asks for. If it asks for multiple options, N variations, a visual, an artifact, or a full exploration, produce ALL of them — do not reduce it to one, and do not cut it short to finish faster.',
3279
- '- At the end of your final report, add exactly one line: MANAGER_REQUIREMENT_EVIDENCE: {"requirements":[{"id":"task-<this run id>","status":"addressed|partial|not-addressed","evidence":"file, test, or Open it evidence","files":["changed/path"]}]}. This labels evidence for Review; it is not self-approval. Report partial or not-addressed honestly.',
3280
+ `- Before your final report, Write ${REQUIREMENT_EVIDENCE_FILE} in the project root as one complete JSON document: {"schemaVersion":1,"requirements":[{"id":"<one of REQUIREMENT IDS FOR THIS RUN>","status":"addressed|partial|not-addressed","evidence":"file, test, or Open it evidence","files":["changed/path"]}]}. Include one row for every requirement ID listed above. This is a worker claim for Review, not self-approval. Report partial or not-addressed honestly. Always overwrite the whole file with Write; do not append it to prose and do not partially Edit it.`,
3280
3281
  '- For a review follow-up, implement and report against CURRENT USER FOLLOW-UP above. Do not claim the first attempt as this attempt\'s result. Do not change shared theme tokens, seed/demo data, or unrelated indicators merely to make a manual check easier unless the user explicitly requested those changes; put test fixtures in test files only.',
3281
3282
  '- Requirement history is context, not a Manager priority decision. Keep prior items visible; if an item remains unaddressed, say so plainly rather than omitting it.',
3282
3283
  '- デザイン案・UI案・レイアウト案を求められた場合は、文章だけで完了にしない。画像を使わない案でも、比較して確認できるHTML等のartifactをプロジェクト内に作り、実際に開ける .manager-run.json を残す。',
@@ -3377,15 +3378,13 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3377
3378
  // Anything we cannot validate is dropped: no "run it" button is honest, a
3378
3379
  // button that opens the wrong thing is the fabricated-proof bug all over again.
3379
3380
  export const RUN_DECL_FILE = '.manager-run.json';
3381
+ export const REQUIREMENT_EVIDENCE_FILE = '.manager-requirement-evidence.json';
3382
+ export const REQUIREMENT_EVIDENCE_SCHEMA_VERSION = 1;
3380
3383
 
3381
3384
  // Structured evidence is a transport record, not a Manager verdict. A worker
3382
3385
  // may append it to its normal final report so each requirement remains visible
3383
3386
  // with the attempt that addressed it.
3384
- export function parseRequirementEvidence(raw) {
3385
- const m = String(raw ?? '').match(/MANAGER_REQUIREMENT_EVIDENCE:\s*(\{[^\n]+\})/);
3386
- if (!m) return null;
3387
- let value;
3388
- try { value = JSON.parse(m[1]); } catch { return null; }
3387
+ function normalizeRequirementClaims(value) {
3389
3388
  if (!Array.isArray(value?.requirements)) return null;
3390
3389
  const requirements = value.requirements.slice(0, 20).map((entry) => {
3391
3390
  const id = typeof entry?.id === 'string' ? entry.id.trim().slice(0, 120) : '';
@@ -3399,11 +3398,68 @@ export function parseRequirementEvidence(raw) {
3399
3398
  return requirements.length ? { requirements } : null;
3400
3399
  }
3401
3400
 
3401
+ // Primary, versioned transport. This parses one complete dedicated document
3402
+ // and returns explicit failure states so malformed evidence cannot disappear.
3403
+ export function parseRequirementEvidenceDocument(raw) {
3404
+ let value;
3405
+ try { value = JSON.parse(String(raw ?? '')); }
3406
+ catch { return { ok: false, error: 'invalid-json', message: 'Requirement evidence was not valid JSON.' }; }
3407
+ if (value?.schemaVersion !== REQUIREMENT_EVIDENCE_SCHEMA_VERSION) {
3408
+ return { ok: false, error: 'unsupported-version', message: `Requirement evidence schemaVersion must be ${REQUIREMENT_EVIDENCE_SCHEMA_VERSION}.` };
3409
+ }
3410
+ const normalized = normalizeRequirementClaims(value);
3411
+ if (!normalized) return { ok: false, error: 'invalid-requirements', message: 'Requirement evidence must contain at least one valid requirement claim.' };
3412
+ return {
3413
+ ok: true,
3414
+ value: {
3415
+ schemaVersion: REQUIREMENT_EVIDENCE_SCHEMA_VERSION,
3416
+ source: 'artifact',
3417
+ state: 'valid',
3418
+ requirements: normalized.requirements,
3419
+ },
3420
+ };
3421
+ }
3422
+
3423
+ // Migration-only fallback for workers launched with an older prompt.
3424
+ export function parseRequirementEvidence(raw) {
3425
+ const m = String(raw ?? '').match(/MANAGER_REQUIREMENT_EVIDENCE:\s*(\{[^\n]+\})/);
3426
+ if (!m) return null;
3427
+ let value;
3428
+ try { value = JSON.parse(m[1]); } catch { return null; }
3429
+ return normalizeRequirementClaims(value);
3430
+ }
3431
+
3402
3432
  export function unmappedChangedFiles(changedFiles = [], evidence = null) {
3403
3433
  const claimed = new Set((evidence?.requirements ?? []).flatMap((r) => r.files ?? []));
3404
3434
  return [...new Set((changedFiles ?? []).filter(Boolean))].filter((file) => !claimed.has(file));
3405
3435
  }
3406
3436
 
3437
+ // "Open it" opened a page the agent wrote about the work, while the work itself
3438
+ // was somewhere else.
3439
+ //
3440
+ // Goal #871: the worker really did change `app/index.html`, and declared
3441
+ // `docs/design/proposals/review-card-declutter-implemented.html` — a page whose
3442
+ // content was the implementation report — as the thing to open. The declaration
3443
+ // is served from the same /upload URL as any other artifact, so on the Review
3444
+ // card it is indistinguishable from the product. The operator pressed Open it,
3445
+ // got a write-up, and asked again for something they could actually look at.
3446
+ //
3447
+ // Declaring a static file is usually right: 15 tasks did it on the operator's
3448
+ // instance 2026-07-25..29 and 14 were comparison pages and reports with nothing
3449
+ // else to show. Only #871 declared one while ALSO changing something else. So
3450
+ // this does not block, score, or guess at intent — Galda deciding what a worker
3451
+ // "really meant" is the mistake #518 was written to undo. It reports one fact
3452
+ // the card cannot currently express: the file being opened is not the only thing
3453
+ // this run touched, and here is what else it touched.
3454
+ //
3455
+ // Returns null when there is nothing to say — no static declaration, or the
3456
+ // declared file is the whole change.
3457
+ export function previewOpensSomethingElse({ runFile = null, changedFiles = [] } = {}) {
3458
+ if (!runFile) return null;
3459
+ const others = [...new Set((changedFiles ?? []).filter(Boolean))].filter((f) => f !== runFile);
3460
+ return others.length ? { opens: runFile, alsoChanged: others } : null;
3461
+ }
3462
+
3407
3463
  // A declaration that parsed as JSON but breaks a rule (bad url scheme, an
3408
3464
  // unsafe `file` path) is NOT the same failure as unparseable JSON — the first
3409
3465
  // is a worker mistake with a specific, sayable reason; the second is a broken
@@ -0,0 +1,68 @@
1
+ const COVERAGE = new Set(['unverified', 'verified', 'failed', 'not-applicable']);
2
+
3
+ function cleanList(value) {
4
+ return [...new Set((Array.isArray(value) ? value : []).filter((item) => typeof item === 'string' && item))];
5
+ }
6
+
7
+ function normalizedCheck(check) {
8
+ if (!check || typeof check !== 'object') return null;
9
+ const kind = typeof check.kind === 'string' ? check.kind.trim() : '';
10
+ const status = typeof check.status === 'string' ? check.status.trim() : '';
11
+ if (!kind || !status) return null;
12
+ return {
13
+ authority: 'manager',
14
+ kind,
15
+ status,
16
+ requirementIds: cleanList(check.requirementIds),
17
+ detail: typeof check.detail === 'string' ? check.detail.trim().slice(0, 1000) : '',
18
+ ...(typeof check.url === 'string' && check.url ? { url: check.url } : {}),
19
+ files: cleanList(check.files),
20
+ };
21
+ }
22
+
23
+ function coverageFromChecks(requirementId, checks) {
24
+ const scoped = checks.filter((check) => check.requirementIds.includes(requirementId));
25
+ if (scoped.some((check) => check.status === 'failed')) return 'failed';
26
+ if (scoped.some((check) => check.status === 'verified')) return 'verified';
27
+ if (scoped.length && scoped.every((check) => check.status === 'not-applicable')) return 'not-applicable';
28
+ return 'unverified';
29
+ }
30
+
31
+ /**
32
+ * Join worker claims and Manager-observed checks by explicit IDs.
33
+ * Unscoped passing checks are retained as facts but never promote coverage.
34
+ */
35
+ export function buildRequirementVerificationEvidence({
36
+ requirementIds = [], attemptId, taskId = null, workerEvidence = null, checks = [], changedFiles = [],
37
+ } = {}) {
38
+ if (!attemptId) return [];
39
+ const ids = cleanList(requirementIds);
40
+ const normalizedChecks = checks.map(normalizedCheck).filter(Boolean);
41
+ const claims = new Map((workerEvidence?.requirements ?? []).map((claim) => [claim.id, claim]));
42
+ const observedFiles = cleanList(changedFiles);
43
+
44
+ return ids.map((requirementId) => {
45
+ const claim = claims.get(requirementId);
46
+ const coverage = coverageFromChecks(requirementId, normalizedChecks);
47
+ return {
48
+ requirementId,
49
+ attemptId,
50
+ taskId,
51
+ claim: claim?.status ?? null,
52
+ coverage: COVERAGE.has(coverage) ? coverage : 'unverified',
53
+ workerEvidence: claim?.evidence ?? '',
54
+ workerFiles: cleanList(claim?.files),
55
+ observedFiles,
56
+ checks: normalizedChecks,
57
+ };
58
+ });
59
+ }
60
+
61
+ export function mergeRequirementVerificationEvidence(existing = [], incoming = []) {
62
+ const rows = new Map();
63
+ for (const row of [...existing, ...incoming]) {
64
+ if (!row?.requirementId || !row?.attemptId) continue;
65
+ rows.set(`${row.requirementId}\u0000${row.attemptId}\u0000${row.taskId ?? ''}`, row);
66
+ }
67
+ return [...rows.values()];
68
+ }
package/engine/server.mjs CHANGED
@@ -21,9 +21,10 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  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
- 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, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
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
+ import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
25
26
  import { createSerialQueue } from './lib.mjs';
26
- import { parseRequirementEvidence, unmappedChangedFiles } from './lib.mjs';
27
+ import { parseRequirementEvidence, unmappedChangedFiles, previewOpensSomethingElse } from './lib.mjs';
27
28
  import { isPrClosed } from './lib.mjs';
28
29
  import { collectProjectRules } from './lib.mjs';
29
30
  import { COMPLETION_CONTRACT_PROMPT, completionContractIssue, parseManagerOutcome, incompleteEvidenceOutcome } from './completion-contract.mjs';
@@ -749,6 +750,45 @@ let tasks = replayed.tasks;
749
750
  let nextId = replayed.nextId;
750
751
  let prioCounter = replayed.prioCounter;
751
752
 
753
+ // Canonical requirement model: read legacy goal/task records without rewriting
754
+ // the append-only log at boot. The next real save persists the normalized
755
+ // shape, so rollout is non-destructive and old binaries can still replay the
756
+ // untouched legacy fields during rollback.
757
+ function refreshRequirementModel(goalId) {
758
+ const goal = goals.find((item) => item.id === goalId);
759
+ if (!goal) return { goal: null, changed: false };
760
+ const goalTasks = tasks.filter((item) => item.goalId === goalId);
761
+ const before = JSON.stringify({
762
+ version: goal.requirementModelVersion,
763
+ requirements: goal.requirements,
764
+ attempts: goal.attempts,
765
+ evidence: goal.requirementEvidence,
766
+ legacyMap: goal.legacyRequirementMap,
767
+ taskRefs: goalTasks.map((item) => [item.id, item.activeRequirementIds]),
768
+ error: goal.requirementModelError,
769
+ });
770
+ const migrated = migrateRequirementModel(goal, goalTasks);
771
+ Object.assign(goal, migrated.goal);
772
+ for (const normalized of migrated.tasks) {
773
+ const task = tasks.find((item) => item.id === normalized.id);
774
+ if (task) task.activeRequirementIds = normalized.activeRequirementIds;
775
+ }
776
+ const validation = validateRequirementModel(goal, goalTasks);
777
+ if (validation.ok) delete goal.requirementModelError;
778
+ else goal.requirementModelError = { kind: 'invalid-requirement-model', errors: validation.errors };
779
+ const after = JSON.stringify({
780
+ version: goal.requirementModelVersion,
781
+ requirements: goal.requirements,
782
+ attempts: goal.attempts,
783
+ evidence: goal.requirementEvidence,
784
+ legacyMap: goal.legacyRequirementMap,
785
+ taskRefs: goalTasks.map((item) => [item.id, item.activeRequirementIds]),
786
+ error: goal.requirementModelError,
787
+ });
788
+ return { goal, changed: before !== after };
789
+ }
790
+ for (const goal of goals) refreshRequirementModel(goal.id);
791
+
752
792
  // Cross-device sync (P3): before anything downstream (orphan reconcile, the
753
793
  // queues Map, workers) is built from this state, adopt a newer board from the
754
794
  // account if one exists — this is where a second device inherits the work on
@@ -859,6 +899,7 @@ function logAppend(line) {
859
899
  catch (e) { console.error(`[manager] log write failed (${e?.code || e}) — state not persisted this write, UI still updated`); return false; }
860
900
  }
861
901
  function saveGoal(g) {
902
+ refreshRequirementModel(g.id);
862
903
  emitGoalQualityEvents(g);
863
904
  logAppend(JSON.stringify({ ...g, kind: 'goal' }));
864
905
  sendGoalEvent({ ev: 'goal', goal: g }, g);
@@ -933,6 +974,12 @@ function saveTask(t) {
933
974
  from: 'ai', via: t.reply ? 'thread' : 'composer', text: t.result,
934
975
  });
935
976
  }
977
+ const canonical = refreshRequirementModel(t.goalId);
978
+ // Persist the canonical goal on first write (or when a new attempt/evidence
979
+ // changes it), not merely in memory. Avoid saveGoal() here: terminal task
980
+ // saves already add conversation messages, and recursively emitting another
981
+ // goal lifecycle event would duplicate analytics/SSE side effects.
982
+ if (canonical.changed && canonical.goal) logAppend(JSON.stringify({ ...canonical.goal, kind: 'goal' }));
936
983
  const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
937
984
  logAppend(JSON.stringify({ ...persist, kind: 'task' }));
938
985
  const goal = goals.find((g) => g.id === t.goalId);
@@ -1023,6 +1070,7 @@ function hydrateFromBoard(snap) {
1023
1070
  catch (e) { console.log(`[sync] hydrate: log write failed (${e?.code || e})`); }
1024
1071
  const r = replayQueueLog(snap.tasksLog || '', reviewDefinitions);
1025
1072
  goals = r.goals; tasks = r.tasks; nextId = r.nextId; prioCounter = r.prioCounter;
1073
+ for (const goal of goals) refreshRequirementModel(goal.id);
1026
1074
  // A pulled 'running' goal has no live worker on this device — reconcile it
1027
1075
  // exactly as a restart would (same rule replayQueueLog callers use at boot).
1028
1076
  for (const fix of reconcileOrphanGoals(goals, tasks)) {
@@ -2320,6 +2368,19 @@ function routeWorkerOutcome(goal, task, project) {
2320
2368
  task.managerOutcome = outcome;
2321
2369
  task.finishedAt = new Date().toISOString();
2322
2370
 
2371
+ // An audit can find that the requested code is already on origin/main. With
2372
+ // no branch diff GitHub cannot create a new PR, but nothing is blocked.
2373
+ // Preserve the verified explanation and continue through normal Review
2374
+ // close-out instead of asking the customer to run git/gh manually.
2375
+ if (outcome.outcome === 'already-satisfied') {
2376
+ goal.prNote = outcome.summary;
2377
+ goal.blocked = null;
2378
+ saveTask(task);
2379
+ saveGoal(goal);
2380
+ sendProcessAct(task, `Already satisfied on the base branch: ${outcome.summary}`);
2381
+ return false;
2382
+ }
2383
+
2323
2384
  if (outcome.outcome === 'rebind-workspace') {
2324
2385
  const rebound = rebindGoalWorkspace(goal, project, outcome.workingDirectory);
2325
2386
  if (rebound.ok) {
@@ -2616,6 +2677,22 @@ function collectRunDeclFileArtifact(task, workDir) {
2616
2677
  sendProcessAct(task, `Worker attached a file to open: ${rel}`);
2617
2678
  }
2618
2679
 
2680
+ // Runs from the caller that computes changedFiles, not from readRunDeclaration:
2681
+ // the declaration is consumed BEFORE the diff is taken (it is a message to the
2682
+ // Manager and must not appear in the PR), so at declaration time there is
2683
+ // nothing yet to compare against.
2684
+ //
2685
+ // This states a fact and stops. It does not withhold the link, mark the task, or
2686
+ // decide the worker showed the wrong thing — a report page beside a code change
2687
+ // is a perfectly good answer to "explain what you did". It just makes the card
2688
+ // able to say which one you are about to open.
2689
+ function notePreviewScope(task) {
2690
+ const scope = previewOpensSomethingElse({ runFile: task.run?.file, changedFiles: task.changedFiles });
2691
+ if (!scope) return;
2692
+ task.run = { ...task.run, alsoChanged: scope.alsoChanged };
2693
+ sendProcessAct(task, `Open it points at ${scope.opens}; this run also changed ${scope.alsoChanged.join(', ').slice(0, 300)}.`);
2694
+ }
2695
+
2619
2696
  // A direct/shared workspace can still contain a declaration left by an older
2620
2697
  // run (for example when that run was interrupted before readRunDeclaration).
2621
2698
  // Remove it before this task starts so only a file created by THIS worker can
@@ -2631,6 +2708,53 @@ function clearStaleRunDeclaration(task, workDir) {
2631
2708
  }
2632
2709
  }
2633
2710
 
2711
+ function clearStaleRequirementEvidence(task, workDir) {
2712
+ const file = join(workDir, REQUIREMENT_EVIDENCE_FILE);
2713
+ if (!existsSync(file)) return;
2714
+ try {
2715
+ unlinkSync(file);
2716
+ sendProcessAct(task, `Removed stale ${REQUIREMENT_EVIDENCE_FILE} before starting this attempt.`);
2717
+ } catch (err) {
2718
+ sendProcessAct(task, `Could not remove stale ${REQUIREMENT_EVIDENCE_FILE}: ${String(err?.code ?? err).slice(0, 80)}`);
2719
+ }
2720
+ }
2721
+
2722
+ // Read the dedicated versioned artifact first. Only an ABSENT artifact falls
2723
+ // back to the legacy prose marker; an artifact that exists but is malformed is
2724
+ // an explicit parse-error and must not be hidden by older report text.
2725
+ function readRequirementEvidence(task, workDir) {
2726
+ const file = join(workDir, REQUIREMENT_EVIDENCE_FILE);
2727
+ let raw;
2728
+ try { raw = readFileSync(file, 'utf8'); }
2729
+ catch {
2730
+ const legacy = parseRequirementEvidence(task.result);
2731
+ task.requirementEvidence = legacy
2732
+ ? { schemaVersion: 0, source: 'legacy-report', state: 'valid', requirements: legacy.requirements }
2733
+ : null;
2734
+ task.requirementEvidenceStatus = legacy
2735
+ ? { state: 'valid', source: 'legacy-report', schemaVersion: 0 }
2736
+ : { state: 'missing', source: 'artifact' };
2737
+ return;
2738
+ }
2739
+ try { unlinkSync(file); } catch { /* best effort: transport file is not a deliverable */ }
2740
+ const parsed = parseRequirementEvidenceDocument(raw);
2741
+ if (parsed.ok) {
2742
+ task.requirementEvidence = parsed.value;
2743
+ task.requirementEvidenceStatus = { state: 'valid', source: 'artifact', schemaVersion: parsed.value.schemaVersion };
2744
+ sendProcessAct(task, `Loaded structured requirement evidence schema v${parsed.value.schemaVersion}.`);
2745
+ return;
2746
+ }
2747
+ task.requirementEvidence = null;
2748
+ task.requirementEvidenceStatus = {
2749
+ state: 'parse-error',
2750
+ source: 'artifact',
2751
+ error: parsed.error,
2752
+ message: parsed.message,
2753
+ snippet: String(raw).slice(0, 200),
2754
+ };
2755
+ sendAct(task, `requirement evidence ignored — ${parsed.message}`);
2756
+ }
2757
+
2634
2758
  function inheritPreviousRunDeclaration(task, goalId) {
2635
2759
  if (task.run || !goalId) return;
2636
2760
  const prev = [...tasks].reverse().find((t) => t.goalId === goalId && t.id !== task.id && t.run && (t.run.cmd || t.run.url));
@@ -2798,6 +2922,7 @@ async function runTask(task) {
2798
2922
  : `Prepared isolated worktree: ${workDir}`);
2799
2923
  if (goal?.workspaceWarning) sendProcessAct(task, goal.workspaceWarning);
2800
2924
  clearStaleRunDeclaration(task, workDir);
2925
+ clearStaleRequirementEvidence(task, workDir);
2801
2926
  const before = await gitChanges(workDir);
2802
2927
  task.attemptId = task.attemptId || newAttemptId(goal, task);
2803
2928
  if (goal) {
@@ -2864,7 +2989,7 @@ async function runTask(task) {
2864
2989
  task.secs = Math.round((Date.now() - t0) / 1000);
2865
2990
  task.status = r.code === 0 ? 'done' : 'failed';
2866
2991
  task.result = r.result.slice(0, RESULT_MAX);
2867
- task.requirementEvidence = parseRequirementEvidence(task.result);
2992
+ readRequirementEvidence(task, workDir);
2868
2993
  task.usage = sumUsage([r.usage]);
2869
2994
  if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2870
2995
  const approval = parseApprovalRequest(r.result);
@@ -2879,6 +3004,7 @@ async function runTask(task) {
2879
3004
  const afterR = await gitChanges(workDir);
2880
3005
  task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2881
3006
  task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
3007
+ notePreviewScope(task);
2882
3008
  task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
2883
3009
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
2884
3010
  // An explicit worker receipt takes precedence over Galda's legacy prose
@@ -3057,7 +3183,7 @@ async function runTask(task) {
3057
3183
  // force-stopped task states WHY so the Attention card explains itself.
3058
3184
  if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
3059
3185
  task.result = result.slice(0, RESULT_MAX);
3060
- task.requirementEvidence = parseRequirementEvidence(task.result);
3186
+ readRequirementEvidence(task, workDir);
3061
3187
  task.usage = sumUsage(usages);
3062
3188
  if (verdict) {
3063
3189
  const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
@@ -3104,6 +3230,7 @@ async function runTask(task) {
3104
3230
  const after = await gitChanges(workDir);
3105
3231
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
3106
3232
  task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
3233
+ notePreviewScope(task);
3107
3234
  task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
3108
3235
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
3109
3236
  // An explicit worker receipt takes precedence over Galda's legacy prose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.96",
3
+ "version": "0.10.97",
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": {