@galda/cli 0.10.95 → 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 +113 -21
- package/engine/chrome-safety.mjs +77 -12
- package/engine/completion-contract.mjs +42 -0
- package/engine/lib.mjs +69 -13
- package/engine/requirement-verification.mjs +68 -0
- package/engine/server.mjs +210 -5
- package/package.json +1 -1
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
|
-
|
|
9888
|
-
|
|
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
|
-
${
|
|
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 (
|
|
10207
|
-
|
|
10208
|
-
|
|
10209
|
-
|
|
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
|
package/engine/chrome-safety.mjs
CHANGED
|
@@ -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 =
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
33
|
-
return chromeUpdatePending(chromePath, options) ? null : chromePath;
|
|
98
|
+
return chromeStatus(chromePath, options).path;
|
|
34
99
|
}
|
|
@@ -40,8 +40,50 @@ 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
|
+
'- 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.',
|
|
43
47
|
].join('\n');
|
|
44
48
|
|
|
49
|
+
const MANAGER_OUTCOMES = new Set(['needs-input', 'blocked', 'rebind-workspace', 'already-satisfied']);
|
|
50
|
+
|
|
51
|
+
// A transport receipt written by the worker, not a Manager inference. Keep the
|
|
52
|
+
// schema deliberately tiny: the agent decides what happened; Galda only checks
|
|
53
|
+
// that the envelope is safe to route.
|
|
54
|
+
export function parseManagerOutcome(raw) {
|
|
55
|
+
const m = String(raw ?? '').match(/MANAGER_OUTCOME:\s*(\{[^\n]+\})/);
|
|
56
|
+
if (!m) return null;
|
|
57
|
+
let value;
|
|
58
|
+
try { value = JSON.parse(m[1]); } catch { return null; }
|
|
59
|
+
if (!MANAGER_OUTCOMES.has(value?.outcome)) return null;
|
|
60
|
+
const summary = typeof value.summary === 'string' ? value.summary.trim().slice(0, 500) : '';
|
|
61
|
+
const question = typeof value.question === 'string' ? value.question.trim().slice(0, 300) : '';
|
|
62
|
+
const options = Array.isArray(value.options)
|
|
63
|
+
? value.options.map((x) => typeof x === 'string' ? x.trim().slice(0, 60) : '').filter(Boolean).slice(0, 4)
|
|
64
|
+
: [];
|
|
65
|
+
const workingDirectory = typeof value.workingDirectory === 'string' ? value.workingDirectory.trim().slice(0, 1000) : '';
|
|
66
|
+
if (value.outcome === 'needs-input' && !question) return null;
|
|
67
|
+
if (value.outcome === 'blocked' && !summary) return null;
|
|
68
|
+
if (value.outcome === 'already-satisfied' && !summary) return null;
|
|
69
|
+
if (value.outcome === 'rebind-workspace' && !workingDirectory.startsWith('/')) return null;
|
|
70
|
+
return { outcome: value.outcome, summary, question, options, workingDirectory };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Existing requirement evidence is also an explicit worker declaration. A
|
|
74
|
+
// worker cannot say `partial`/`not-addressed` and simultaneously be routed to
|
|
75
|
+
// Review as complete. This is schema consistency, not semantic judgment.
|
|
76
|
+
export function incompleteEvidenceOutcome(evidence) {
|
|
77
|
+
const incomplete = (evidence?.requirements ?? []).filter((r) => ['partial', 'not-addressed'].includes(r?.status));
|
|
78
|
+
if (!incomplete.length) return null;
|
|
79
|
+
return {
|
|
80
|
+
outcome: 'blocked',
|
|
81
|
+
summary: incomplete.map((r) => r.evidence || `${r.id}: ${r.status}`).filter(Boolean).join('; ').slice(0, 500)
|
|
82
|
+
|| 'The worker declared one or more requirements incomplete.',
|
|
83
|
+
question: '', options: [], workingDirectory: '',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
45
87
|
export function completionContractIssue({ request = '', followUp = '', result = '', status = '', changedFiles = [] } = {}) {
|
|
46
88
|
if (status !== 'done') return null;
|
|
47
89
|
const requested = `${request}\n${followUp}`;
|
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
|
-
//
|
|
2484
|
-
//
|
|
2485
|
-
//
|
|
2486
|
-
//
|
|
2487
|
-
//
|
|
2488
|
-
export
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,12 +21,13 @@ 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
|
-
import { COMPLETION_CONTRACT_PROMPT, completionContractIssue } from './completion-contract.mjs';
|
|
30
|
+
import { COMPLETION_CONTRACT_PROMPT, completionContractIssue, parseManagerOutcome, incompleteEvidenceOutcome } from './completion-contract.mjs';
|
|
30
31
|
import { FALLBACK_CODEX_MODELS, loadCodexModels } from './codex-models.mjs';
|
|
31
32
|
import { CLAUDE_MODELS } from './claude-models.mjs';
|
|
32
33
|
import { classifyWorkerPreflight, shouldProbeClaudeAuth, workerCanStart } from './worker-availability.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)) {
|
|
@@ -2310,6 +2358,79 @@ function pauseGoalForApproval(goal, task, request) {
|
|
|
2310
2358
|
return true;
|
|
2311
2359
|
}
|
|
2312
2360
|
|
|
2361
|
+
// The worker decides where its result belongs. Galda does not classify prose;
|
|
2362
|
+
// it only validates and transports the explicit receipt (or the worker's
|
|
2363
|
+
// existing partial/not-addressed evidence) into the corresponding board state.
|
|
2364
|
+
function routeWorkerOutcome(goal, task, project) {
|
|
2365
|
+
if (!goal || !task || task.status !== 'done') return false;
|
|
2366
|
+
const outcome = parseManagerOutcome(task.result) || incompleteEvidenceOutcome(task.requirementEvidence);
|
|
2367
|
+
if (!outcome) return false; // backwards-compatible completed run
|
|
2368
|
+
task.managerOutcome = outcome;
|
|
2369
|
+
task.finishedAt = new Date().toISOString();
|
|
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
|
+
|
|
2384
|
+
if (outcome.outcome === 'rebind-workspace') {
|
|
2385
|
+
const rebound = rebindGoalWorkspace(goal, project, outcome.workingDirectory);
|
|
2386
|
+
if (rebound.ok) {
|
|
2387
|
+
task.status = 'skipped';
|
|
2388
|
+
task.result = `${task.result}\n\n[Galda: continuing in ${rebound.dir} with a fresh session]`.slice(0, RESULT_MAX);
|
|
2389
|
+
saveTask(task);
|
|
2390
|
+
goal.status = 'running';
|
|
2391
|
+
goal.startedAt = null;
|
|
2392
|
+
goal.question = null;
|
|
2393
|
+
goal.blocked = null;
|
|
2394
|
+
saveGoal(goal);
|
|
2395
|
+
const continuation = queueReplyTask(goal, [
|
|
2396
|
+
`Continue the same request in the newly bound working directory: ${rebound.dir}`,
|
|
2397
|
+
outcome.summary,
|
|
2398
|
+
'Inspect the files already present there and complete the original request. Do not repeat the old sandbox diagnosis.',
|
|
2399
|
+
].filter(Boolean).join('\n'), { from: 'manager', via: 'system' });
|
|
2400
|
+
continuation.workspaceRebindContinuation = true;
|
|
2401
|
+
saveTask(continuation);
|
|
2402
|
+
sendProcessAct(task, `Worker requested a workspace rebind; continuing in ${rebound.dir} with a fresh session.`);
|
|
2403
|
+
return true;
|
|
2404
|
+
}
|
|
2405
|
+
// An unsafe/unavailable path is not silently retried in the old sandbox.
|
|
2406
|
+
outcome.outcome = 'needs-input';
|
|
2407
|
+
outcome.question = rebound.error;
|
|
2408
|
+
outcome.options = [];
|
|
2409
|
+
outcome.summary = rebound.error;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// This turn ended successfully as a routing hand-off, not as a failed unit
|
|
2413
|
+
// of implementation. Keeping it `interrupted` would poison the eventual
|
|
2414
|
+
// closeout even after the user answers and the continuation completes.
|
|
2415
|
+
task.status = 'skipped';
|
|
2416
|
+
saveTask(task);
|
|
2417
|
+
goal.startedAt = null;
|
|
2418
|
+
if (outcome.outcome === 'needs-input') {
|
|
2419
|
+
goal.status = 'needsInput';
|
|
2420
|
+
goal.blocked = null;
|
|
2421
|
+
goal.question = { kind: 'worker', text: outcome.question, options: outcome.options };
|
|
2422
|
+
sendProcessAct(task, `Worker asked for input: ${outcome.question}`);
|
|
2423
|
+
} else {
|
|
2424
|
+
goal.status = 'blocked';
|
|
2425
|
+
goal.question = null;
|
|
2426
|
+
goal.blocked = { kind: 'worker-outcome', reason: outcome.summary };
|
|
2427
|
+
sendProcessAct(task, `Worker declared the work blocked: ${outcome.summary}`);
|
|
2428
|
+
}
|
|
2429
|
+
saveGoal(goal);
|
|
2430
|
+
startNextStacked(goal.projectId);
|
|
2431
|
+
return true;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2313
2434
|
// split (docs/design/ONE-CONVERSATION.md §1.3): 「それは別件」と返された時の受け皿。
|
|
2314
2435
|
// 元のゴールには触らない——新しいチケットを1枚起こし、そこに「どこから生まれたか」だけ
|
|
2315
2436
|
// 片方向の印(bornFrom)を残す。
|
|
@@ -2556,6 +2677,22 @@ function collectRunDeclFileArtifact(task, workDir) {
|
|
|
2556
2677
|
sendProcessAct(task, `Worker attached a file to open: ${rel}`);
|
|
2557
2678
|
}
|
|
2558
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
|
+
|
|
2559
2696
|
// A direct/shared workspace can still contain a declaration left by an older
|
|
2560
2697
|
// run (for example when that run was interrupted before readRunDeclaration).
|
|
2561
2698
|
// Remove it before this task starts so only a file created by THIS worker can
|
|
@@ -2571,6 +2708,53 @@ function clearStaleRunDeclaration(task, workDir) {
|
|
|
2571
2708
|
}
|
|
2572
2709
|
}
|
|
2573
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
|
+
|
|
2574
2758
|
function inheritPreviousRunDeclaration(task, goalId) {
|
|
2575
2759
|
if (task.run || !goalId) return;
|
|
2576
2760
|
const prev = [...tasks].reverse().find((t) => t.goalId === goalId && t.id !== task.id && t.run && (t.run.cmd || t.run.url));
|
|
@@ -2738,6 +2922,7 @@ async function runTask(task) {
|
|
|
2738
2922
|
: `Prepared isolated worktree: ${workDir}`);
|
|
2739
2923
|
if (goal?.workspaceWarning) sendProcessAct(task, goal.workspaceWarning);
|
|
2740
2924
|
clearStaleRunDeclaration(task, workDir);
|
|
2925
|
+
clearStaleRequirementEvidence(task, workDir);
|
|
2741
2926
|
const before = await gitChanges(workDir);
|
|
2742
2927
|
task.attemptId = task.attemptId || newAttemptId(goal, task);
|
|
2743
2928
|
if (goal) {
|
|
@@ -2804,7 +2989,7 @@ async function runTask(task) {
|
|
|
2804
2989
|
task.secs = Math.round((Date.now() - t0) / 1000);
|
|
2805
2990
|
task.status = r.code === 0 ? 'done' : 'failed';
|
|
2806
2991
|
task.result = r.result.slice(0, RESULT_MAX);
|
|
2807
|
-
task
|
|
2992
|
+
readRequirementEvidence(task, workDir);
|
|
2808
2993
|
task.usage = sumUsage([r.usage]);
|
|
2809
2994
|
if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
|
|
2810
2995
|
const approval = parseApprovalRequest(r.result);
|
|
@@ -2819,8 +3004,12 @@ async function runTask(task) {
|
|
|
2819
3004
|
const afterR = await gitChanges(workDir);
|
|
2820
3005
|
task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2821
3006
|
task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
|
|
3007
|
+
notePreviewScope(task);
|
|
2822
3008
|
task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
|
|
2823
3009
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
|
|
3010
|
+
// An explicit worker receipt takes precedence over Galda's legacy prose
|
|
3011
|
+
// guard. The agent owns this decision; Manager only transports it.
|
|
3012
|
+
if (routeWorkerOutcome(goal, task, project)) return;
|
|
2824
3013
|
const completionIssue = completionContractIssue({
|
|
2825
3014
|
request: goal?.text,
|
|
2826
3015
|
followUp: task.detail,
|
|
@@ -2994,7 +3183,7 @@ async function runTask(task) {
|
|
|
2994
3183
|
// force-stopped task states WHY so the Attention card explains itself.
|
|
2995
3184
|
if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
|
|
2996
3185
|
task.result = result.slice(0, RESULT_MAX);
|
|
2997
|
-
task
|
|
3186
|
+
readRequirementEvidence(task, workDir);
|
|
2998
3187
|
task.usage = sumUsage(usages);
|
|
2999
3188
|
if (verdict) {
|
|
3000
3189
|
const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
|
|
@@ -3041,8 +3230,12 @@ async function runTask(task) {
|
|
|
3041
3230
|
const after = await gitChanges(workDir);
|
|
3042
3231
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
3043
3232
|
task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
|
|
3233
|
+
notePreviewScope(task);
|
|
3044
3234
|
task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
|
|
3045
3235
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
|
|
3236
|
+
// An explicit worker receipt takes precedence over Galda's legacy prose
|
|
3237
|
+
// guard. The agent owns this decision; Manager only transports it.
|
|
3238
|
+
if (routeWorkerOutcome(goal, task, project)) return;
|
|
3046
3239
|
// task 450: taskStatusAfterVerify decided 'done' from `code` alone, before
|
|
3047
3240
|
// changedFiles existed to check against. Don't trust a clean exit code by
|
|
3048
3241
|
// itself — a worker that changed nothing and left a cut-off-looking result
|
|
@@ -5143,6 +5336,18 @@ const server = createServer(async (req, res) => {
|
|
|
5143
5336
|
let answer = '';
|
|
5144
5337
|
try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
|
|
5145
5338
|
if (!answer) return json(res, 400, { error: 'answer required' });
|
|
5339
|
+
// A worker-authored question is a transport pause, not a new planning
|
|
5340
|
+
// cycle. Preserve the worker session and pass the user's answer straight
|
|
5341
|
+
// back to it; Galda must not reinterpret or rewrite the answer.
|
|
5342
|
+
if (goal.question?.kind === 'worker') {
|
|
5343
|
+
goal.question = null;
|
|
5344
|
+
goal.blocked = null;
|
|
5345
|
+
goal.status = 'running';
|
|
5346
|
+
goal.startedAt = null;
|
|
5347
|
+
saveGoal(goal);
|
|
5348
|
+
const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
|
|
5349
|
+
return json(res, 200, { ...goal, replyTaskId: replyTask.id });
|
|
5350
|
+
}
|
|
5146
5351
|
// Bind this goal to a project rooted at `dir` and start planning. Shared by
|
|
5147
5352
|
// the "picked a repo" and "git-init'd a new folder" paths below.
|
|
5148
5353
|
const bindAndPlan = (dir) => {
|
package/package.json
CHANGED