@galda/cli 0.10.89 → 0.10.91
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 +63 -55
- package/engine/lib.mjs +92 -8
- package/engine/requirement-model.mjs +196 -0
- package/engine/server.mjs +119 -17
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -294,17 +294,6 @@
|
|
|
294
294
|
.gearpop{}, so out here it was invalid at computed-value time and fell back to
|
|
295
295
|
transparent — measured rgba(0,0,0,0). The chips were never filled to begin with. */
|
|
296
296
|
.ctxbar{display:flex;align-items:center;gap:6px;padding:0 2px 7px;background:none;border:0}
|
|
297
|
-
/* Feature A: "Replying to #NNN ✕" chip — neutral hairline pill above the composer
|
|
298
|
-
(real tokens only → theme-adaptive; the ✕ hover moves glyph colour only, §5.5). */
|
|
299
|
-
.replybind{display:flex;align-items:center;gap:6px;max-width:var(--fscenterw,760px);width:100%;margin:0 auto 6px;padding:0 2px}
|
|
300
|
-
.replybind[hidden]{display:none}
|
|
301
|
-
.replybind .rb-seg{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
|
|
302
|
-
border:1px solid var(--hair);background:var(--panel);font:500 12px/1 var(--ui);color:var(--ink2)}
|
|
303
|
-
.replybind .rb-seg b{color:var(--ink);font-weight:600;font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
304
|
-
.replybind .rb-x{display:grid;place-items:center;width:24px;height:24px;border:0;border-radius:7px;background:transparent;
|
|
305
|
-
color:var(--ink3);cursor:pointer;transition:color .12s;flex:0 0 24px}
|
|
306
|
-
.replybind .rb-x:hover{color:var(--ink)}
|
|
307
|
-
.replybind .rb-x svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
308
297
|
.ctxchip{display:inline-flex;align-items:center;height:26px;padding:0;gap:0;border-radius:8px;
|
|
309
298
|
border:1px solid var(--hair);background:transparent;position:relative;min-width:0}
|
|
310
299
|
.ctxsel{position:relative}
|
|
@@ -3037,9 +3026,6 @@
|
|
|
3037
3026
|
reply exists (progressive disclosure — DESIGN-RULES §1.5-3, "存在
|
|
3038
3027
|
しないものの器を先に見せない"). -->
|
|
3039
3028
|
<div class="peraanswer" id="chatReply" hidden></div>
|
|
3040
|
-
<!-- Feature A: when the composer is bound to the focused goal, this chip shows
|
|
3041
|
-
which conversation you're replying to; ✕ detaches back to new-goal mode. -->
|
|
3042
|
-
<div class="replybind" id="replyBind" hidden><span class="rb-seg"><span id="replyBindLabel">Replying to</span> <b id="replyBindNum"></b></span><button type="button" class="rb-x" id="replyBindX" title="New goal instead" aria-label="Detach"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
3043
3029
|
<div class="composer">
|
|
3044
3030
|
<div class="attachbar" id="attachbar" style="display:none"></div>
|
|
3045
3031
|
<!-- Slash-command palette (Masa決定 2026-07-07: chips→slash). Floats
|
|
@@ -4367,7 +4353,6 @@ function mergeGoalTimeline(goals, externalActivity, projectId){
|
|
|
4367
4353
|
function goToGoalInChat(goalId, bindComposer = true){
|
|
4368
4354
|
state.selectedGoal = goalId;
|
|
4369
4355
|
if (bindComposer) state.composerDetachedGoal = null;
|
|
4370
|
-
renderReplyBind(); // Feature A: the composer's "Replying to #NNN" chip follows the focused goal — renderStream() alone skips it (only render() calls it)
|
|
4371
4356
|
renderStream();
|
|
4372
4357
|
const goalEl = document.querySelector(`#stream [data-goalmsg="${goalId}"]`);
|
|
4373
4358
|
if (!goalEl) return;
|
|
@@ -5860,6 +5845,18 @@ function renderReviewAttach(){
|
|
|
5860
5845
|
// 関わらず全部「直して」で、「なんでこうしたの?」と聞いただけでゴールが Doing に戻り
|
|
5861
5846
|
// ワーカーが走っていた。人はモードを普段選ばない——AI が迷った時だけ選ぶ。
|
|
5862
5847
|
const REPLY_CHOICE_LABEL = { continue: 'Fix this', rescope: 'Rethink it', split: 'Separate ticket', answer: 'Just a question' };
|
|
5848
|
+
function replyChoiceLabel(choice){
|
|
5849
|
+
if (choice?.startsWith('project:')) {
|
|
5850
|
+
const id = choice.slice('project:'.length);
|
|
5851
|
+
return `Implement in ${state.projects?.find((project) => project.id === id)?.name || id}`;
|
|
5852
|
+
}
|
|
5853
|
+
return REPLY_CHOICE_LABEL[choice] ?? choice;
|
|
5854
|
+
}
|
|
5855
|
+
function replyChoices(body){
|
|
5856
|
+
const routes = body?.choices || REPLY_OUTCOMES_CLIENT;
|
|
5857
|
+
const targets = (body?.projectChoices || []).filter((project) => !project.current).map((project) => `project:${project.id}`);
|
|
5858
|
+
return [...routes, ...targets];
|
|
5859
|
+
}
|
|
5863
5860
|
// The one waiting line's two labels. Local to the reply note on purpose — STATUS_LABEL
|
|
5864
5861
|
// is the task/workflow vocabulary and this is neither (CDO §5).
|
|
5865
5862
|
const REPLY_WAIT_ROUTING = 'Thinking…';
|
|
@@ -5867,9 +5864,9 @@ const REPLY_WAIT_ANSWERING = 'Answering…';
|
|
|
5867
5864
|
function replyWorkerPayload(extra = {}){
|
|
5868
5865
|
return { ...extra, agent: state.connectedApp, model: state.stickyModel, effort: state.stickyEffort };
|
|
5869
5866
|
}
|
|
5870
|
-
async function postReply(goalId, text, outcome = 'auto'){
|
|
5867
|
+
async function postReply(goalId, text, outcome = 'auto', targetProjectId = null){
|
|
5871
5868
|
const r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
|
|
5872
|
-
headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text, outcome })) });
|
|
5869
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text, outcome, targetProjectId })) });
|
|
5873
5870
|
if (!r.ok) throw new Error();
|
|
5874
5871
|
return r.json();
|
|
5875
5872
|
}
|
|
@@ -6009,7 +6006,7 @@ function saCardThreadHtml(it){
|
|
|
6009
6006
|
: it.rstatus === 'working' ? replyWorkingHtml()
|
|
6010
6007
|
: it.rstatus === 'unsure' ? (replyNoteRowHtml('Which one is this?')
|
|
6011
6008
|
+ `<div class="qchips">${(it.rchoices || []).map((c) =>
|
|
6012
|
-
`<button type="button" class="qchip" data-rc="${esc(c)}" data-rcgoal="${it.goalId}">${esc(
|
|
6009
|
+
`<button type="button" class="qchip" data-rc="${esc(c)}" data-rcgoal="${it.goalId}">${esc(replyChoiceLabel(c))}</button>`).join('')}</div>`)
|
|
6013
6010
|
: '';
|
|
6014
6011
|
return rows + trailer;
|
|
6015
6012
|
}
|
|
@@ -6054,7 +6051,7 @@ function askReplyDestination(anchorSel, choices){
|
|
|
6054
6051
|
// The You row stays above the chips so "this" has something to point at (CDO Q5).
|
|
6055
6052
|
setReplyNote(anchorSel, replyPastHtml() + said + replyNoteRowHtml('Which one is this?')
|
|
6056
6053
|
+ `<div class="qchips">${(choices || []).map((c) =>
|
|
6057
|
-
`<button type="button" class="qchip" data-rc="${esc(c)}">${esc(
|
|
6054
|
+
`<button type="button" class="qchip" data-rc="${esc(c)}">${esc(replyChoiceLabel(c))}</button>`).join('')}</div>`, resolve);
|
|
6058
6055
|
});
|
|
6059
6056
|
}
|
|
6060
6057
|
// answer (§1.3): nothing was built, so there is no task to watch — the reply comes
|
|
@@ -6085,9 +6082,11 @@ async function replyToGoal(goalId, text, anchorSel){
|
|
|
6085
6082
|
showReplySent(anchorSel, text, Number(goalId)); // before the round trip: the words stay, and it says it is thinking
|
|
6086
6083
|
let body = await postReply(goalId, text, 'auto');
|
|
6087
6084
|
if (body.outcome === 'unsure') {
|
|
6088
|
-
const picked = await askReplyDestination(anchorSel, body
|
|
6085
|
+
const picked = await askReplyDestination(anchorSel, replyChoices(body));
|
|
6089
6086
|
if (!picked) return null;
|
|
6090
|
-
body =
|
|
6087
|
+
body = picked.startsWith('project:')
|
|
6088
|
+
? await postReply(goalId, text, 'split', picked.slice('project:'.length))
|
|
6089
|
+
: await postReply(goalId, text, picked);
|
|
6091
6090
|
}
|
|
6092
6091
|
// seen + 1: the question itself was just recorded; the answer is the one after it.
|
|
6093
6092
|
if (body.outcome === 'answer') showAnswerWhenItArrives(anchorSel, goalId, seen + 1);
|
|
@@ -6237,13 +6236,14 @@ function summarizeAttention(goal, tasks){
|
|
|
6237
6236
|
decision = ja
|
|
6238
6237
|
? 'worker に失敗したテスト出力を確認させ、回帰を修正して、プロジェクトのテストを再実行するよう指示してください。'
|
|
6239
6238
|
: 'Ask the worker to inspect the failing test output, fix the regression, and rerun the project tests.';
|
|
6240
|
-
} else if (/interrupted/i.test(goal.status || '') || /interrupted/i.test(compact)) {
|
|
6239
|
+
} else if (failed?.status === 'interrupted' || /interrupted/i.test(goal.status || '') || /interrupted/i.test(compact)) {
|
|
6240
|
+
const noReport = !compact;
|
|
6241
6241
|
happened = ja
|
|
6242
|
-
? `必要だったこと: ${need}。${taskName ? `「${taskName}」の作業中に、` : ''}
|
|
6243
|
-
: `Needed: ${need}. The run was interrupted${taskName ? ` while working on "${taskName}"` : ''}: ${compact.slice(0, 140)
|
|
6242
|
+
? `必要だったこと: ${need}。${taskName ? `「${taskName}」の作業中に、` : ''}${noReport ? 'Managerとworkerの接続が完了報告前に切れました。run limitだったとは確認できません' : `実行が中断されました: ${compact.slice(0, 140)}`}。`
|
|
6243
|
+
: `Needed: ${need}. ${noReport ? 'The Manager lost its worker connection before a final report was saved; this is not confirmed as a run-limit stop' : `The run was interrupted${taskName ? ` while working on "${taskName}"` : ''}: ${compact.slice(0, 140)}`}.`;
|
|
6244
6244
|
decision = ja
|
|
6245
|
-
? '
|
|
6246
|
-
: 'Resume
|
|
6245
|
+
? '依頼を言い直したり範囲を狭めたりせず、そのまま Resume できます。既存の作業ファイルから続行します。'
|
|
6246
|
+
: 'Resume the same request without rewording or narrowing it. Work continues from the existing files.';
|
|
6247
6247
|
} else if (/blocked/i.test(goal.status || '') || compact) {
|
|
6248
6248
|
happened = ja
|
|
6249
6249
|
? `必要だったこと: ${need}。Review に進む前に停止しました${compact ? `: ${compact.slice(0, 160)}` : ''}。`
|
|
@@ -6422,7 +6422,6 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
|
|
|
6422
6422
|
function render(){
|
|
6423
6423
|
renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
|
|
6424
6424
|
renderCtxBar(); // the folder above the composer follows the active project
|
|
6425
|
-
renderReplyBind(); // Feature A: show/hide the "Replying to #NNN" chip for the focused goal
|
|
6426
6425
|
renderFlagship();
|
|
6427
6426
|
if (state.reviewOpenGoal != null) renderReviewChecklist();
|
|
6428
6427
|
if (state.goalDetailOpen != null) renderGoalDetail();
|
|
@@ -6507,7 +6506,12 @@ function renderFsRail(){
|
|
|
6507
6506
|
const running = pgoals.some((g) => fsBoardBucket(g, ptasks) === 'doing'
|
|
6508
6507
|
&& !(g.status === 'blocked' && g.blocked?.kind === 'paused'));
|
|
6509
6508
|
const td = ptasks.filter((t) => ['running', 'queued'].includes(t.status)).length;
|
|
6510
|
-
|
|
6509
|
+
// The rail's eye icon is the review/attention count. Needs-you goals
|
|
6510
|
+
// (needsInput/needsApproval/blocked, minus Paused) block on the person the
|
|
6511
|
+
// same way an open Review does, so they belong in the same count (Masa
|
|
6512
|
+
// task 854: "needs youも目のアイコンのマークにいれれますか?").
|
|
6513
|
+
const needsCount = pgoals.filter((g) => fsBoardBucket(g, ptasks) === 'needs').length;
|
|
6514
|
+
const rev = buildReviewDigest({ goals: pgoals, tasks: ptasks }).length + needsCount;
|
|
6511
6515
|
const pcount = `<span class="pcount" title="${rev} review${rev === 1 ? '' : 's'} ready · ${td} to-do">`
|
|
6512
6516
|
+ `<span class="pc-item rev${rev ? '' : ' zero'}">${FS_ICO_SCAN}${rev}</span>`
|
|
6513
6517
|
+ `<span class="pc-item td">${FS_ICO_TD}${td}</span></span>`;
|
|
@@ -7251,7 +7255,9 @@ function taskErrorMeta(t, goal){
|
|
|
7251
7255
|
// Reclassify interrupted tasks from their concrete exit text instead of
|
|
7252
7256
|
// trusting stale persisted metadata. Older records labelled an external
|
|
7253
7257
|
// SIGTERM/restart as an Agent failure and permanently carried a Codex switch.
|
|
7254
|
-
const inferred =
|
|
7258
|
+
const inferred = t.status === 'interrupted' && !String(t.result || '').trim()
|
|
7259
|
+
? { owner: 'galda-infra', reason: 'interrupted', actionIds: ['retry', 'view-log'] }
|
|
7260
|
+
: inferErrorClass(t.result, t.agent || goal?.agent);
|
|
7255
7261
|
const raw = t.status === 'interrupted' ? inferred : (t.errorClass || inferred);
|
|
7256
7262
|
const meta = { ...raw, actionIds: (raw.actionIds || []).filter((id) => !['run-with-codex', 'run-with-claude'].includes(id)) };
|
|
7257
7263
|
const lang = failureLang(), C = FAILURE_COPY[lang];
|
|
@@ -9013,22 +9019,6 @@ function replyBinding(){
|
|
|
9013
9019
|
if (REPLY_PLAIN.has(g.status)) return { goal: g, mode: 'plain' };
|
|
9014
9020
|
return null;
|
|
9015
9021
|
}
|
|
9016
|
-
// The visible, reversible "Replying to #NNN ✕" chip above the composer — never
|
|
9017
|
-
// silent (shown whenever bound), always detachable (✕ clears the binding).
|
|
9018
|
-
function renderReplyBind(){
|
|
9019
|
-
const el = $('replyBind'); if (!el) return;
|
|
9020
|
-
const b = replyBinding();
|
|
9021
|
-
el.hidden = !b;
|
|
9022
|
-
const inp = $('input');
|
|
9023
|
-
if (b) {
|
|
9024
|
-
const n = goalReviewNumber({ goal: b.goal, tasks: state.tasks }) ?? b.goal.id;
|
|
9025
|
-
const numEl = $('replyBindNum'); if (numEl) numEl.textContent = '#' + n;
|
|
9026
|
-
const lblEl = $('replyBindLabel'); if (lblEl) lblEl.textContent = b.mode === 'answer' ? 'Answering' : 'Replying to';
|
|
9027
|
-
if (inp) inp.placeholder = b.mode === 'answer' ? `Answering #${n}…` : `Reply to #${n}…`;
|
|
9028
|
-
} else if (inp) {
|
|
9029
|
-
inp.placeholder = 'Describe a goal… ( / for commands )';
|
|
9030
|
-
}
|
|
9031
|
-
}
|
|
9032
9022
|
async function send(){
|
|
9033
9023
|
const { text, model, effort, mode, skill, later, review } = resolveComposer($('input').value);
|
|
9034
9024
|
if (!text) return;
|
|
@@ -9264,7 +9254,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
9264
9254
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
9265
9255
|
// settles it (see saSync's "returned to review" reset).
|
|
9266
9256
|
// ============================================================================
|
|
9267
|
-
const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null };
|
|
9257
|
+
const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null, lastMarkup: null };
|
|
9268
9258
|
function saReplyAttachmentsHtml(goalId){
|
|
9269
9259
|
return (SA.attachments[goalId] || []).map((a, i) => `<span class="sa-replythumb"><img src="${esc(a.url)}" alt="Attached image"><button type="button" data-saarm="${goalId}:${i}" aria-label="Remove image">×</button></span>`).join('');
|
|
9270
9260
|
}
|
|
@@ -9954,7 +9944,9 @@ function saItemHtml(it, i){
|
|
|
9954
9944
|
const tests = it.testResult?.ran
|
|
9955
9945
|
? (it.testResult.ok ? 'Verification completed before the interruption.' : 'Verification remains incomplete or failed.')
|
|
9956
9946
|
: 'Verification has not completed yet.';
|
|
9957
|
-
const reason = String(t.result ||
|
|
9947
|
+
const reason = String(t.result || (failureLang() === 'ja'
|
|
9948
|
+
? 'Managerとworkerの接続が完了報告前に切れました。run limitだったとは確認できません。作業ファイルを保持したままResumeできます。'
|
|
9949
|
+
: 'The Manager lost its worker connection before a final report was saved. This is not confirmed as a run-limit stop. Resume keeps the existing work files.')).replace(/\s+/g, ' ').slice(0, 240);
|
|
9958
9950
|
return `<div class="rinfra"><p class="rbody v"><b>Interrupted — resume available</b><br>${esc(reason)}</p>`
|
|
9959
9951
|
+ (last ? `<p class="rbody v">Last recorded step: ${esc(String(last).slice(0, 220))}</p>` : '')
|
|
9960
9952
|
+ (files.length ? `<p class="rbody v">Changed so far: ${esc(files.join(' · '))}</p>` : '')
|
|
@@ -10238,6 +10230,9 @@ function saRender(){
|
|
|
10238
10230
|
const previousBody = S.querySelector('.sa-body');
|
|
10239
10231
|
const previousScrollTop = previousBody?.scrollTop ?? 0;
|
|
10240
10232
|
const restoreScroll = Boolean(previousBody) && SA.focusGid == null;
|
|
10233
|
+
const focusedReplyGoal = document.activeElement?.matches?.('#seeall .sa-chat')
|
|
10234
|
+
? document.activeElement.dataset.goal
|
|
10235
|
+
: null;
|
|
10241
10236
|
const pending = SA.items.filter((x) => x.st === 0);
|
|
10242
10237
|
const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
|
|
10243
10238
|
// Solo mode = a single "Needs you" goal opened as this card. It is not a review batch, so the
|
|
@@ -10248,8 +10243,16 @@ function saRender(){
|
|
|
10248
10243
|
const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
|
|
10249
10244
|
const posLabel = solo ? '' : (pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '');
|
|
10250
10245
|
const hdKeys = solo ? '↑↓ MOVE · ESC CLOSE' : '↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE';
|
|
10251
|
-
|
|
10246
|
+
const markup = `<div class="sa-panel"><div class="sa-hd"><span class="t">${hdTitle}</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">${hdKeys}</span><button class="sa-x" data-saact="close:0">✕</button></div>
|
|
10252
10247
|
<div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
|
|
10248
|
+
// Most SSE goal/task events are unrelated to the card the person is reading.
|
|
10249
|
+
// Replacing #seeall with identical markup destroyed and recreated every node,
|
|
10250
|
+
// producing a blank flash and resetting browser-owned state. Keep the current
|
|
10251
|
+
// dialog DOM when its semantic markup did not change; the board behind it can
|
|
10252
|
+
// still refresh normally.
|
|
10253
|
+
if (SA.lastMarkup === markup && S.querySelector('.sa-panel')) return;
|
|
10254
|
+
SA.lastMarkup = markup;
|
|
10255
|
+
S.innerHTML = markup;
|
|
10253
10256
|
const body = S.querySelector('.sa-body');
|
|
10254
10257
|
if (body && restoreScroll) body.scrollTop = previousScrollTop;
|
|
10255
10258
|
// Once the person scrolls, their position wins over the short opening-focus
|
|
@@ -10303,6 +10306,10 @@ function saRender(){
|
|
|
10303
10306
|
if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
|
|
10304
10307
|
});
|
|
10305
10308
|
}
|
|
10309
|
+
// A relevant status change may legitimately rebuild the card. Restore the
|
|
10310
|
+
// empty reply box's focus afterwards; non-empty input is protected earlier
|
|
10311
|
+
// by saSync and is never rebuilt at all.
|
|
10312
|
+
if (focusedReplyGoal != null) S.querySelector(`.sa-chat[data-goal="${focusedReplyGoal}"]`)?.focus({ preventScroll: true });
|
|
10306
10313
|
}
|
|
10307
10314
|
// keep the open Ledger in sync with server state (SSE/refresh → render() → here).
|
|
10308
10315
|
// An item whose goal has RETURNED to 'review' (retest ×3 green, rework done,
|
|
@@ -10350,6 +10357,7 @@ async function saFetchDiffs(){
|
|
|
10350
10357
|
}
|
|
10351
10358
|
function saOpen(focusGoalId, solo = false){
|
|
10352
10359
|
SA.open = true;
|
|
10360
|
+
SA.lastMarkup = null; // a newly opened/focused dialog always gets a clean first paint
|
|
10353
10361
|
SA.solo = solo ? focusGoalId : null; // solo = a single "Needs you" goal opened as one card
|
|
10354
10362
|
SA.items = saLedgerRows().map((r) => ({ ...r, st: 0 }));
|
|
10355
10363
|
SA.detOpen = new Set();
|
|
@@ -10380,7 +10388,7 @@ function saOpen(focusGoalId, solo = false){
|
|
|
10380
10388
|
saFetchDiffs();
|
|
10381
10389
|
saEnhanceShotBoxes();
|
|
10382
10390
|
}
|
|
10383
|
-
function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
10391
|
+
function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; SA.lastMarkup = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
10384
10392
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
10385
10393
|
// ↑↓ and auto-advance to the next pending after each verdict — same screen, same
|
|
10386
10394
|
// Ledger, so approving flows without hunting for the next one.
|
|
@@ -10419,8 +10427,8 @@ async function saCall(ids, path, body){
|
|
|
10419
10427
|
// (outcome:'auto', ONE-CONVERSATION §1.4) — this layer never guesses continue vs
|
|
10420
10428
|
// question. Unlike saAct it does NOT show its outcome first: "Feedback sent" would
|
|
10421
10429
|
// be a lie when the reply was a question.
|
|
10422
|
-
async function saReplyCall(it, text, outcome){
|
|
10423
|
-
const r = await saCall(it.ids, 'dismiss', { text, outcome });
|
|
10430
|
+
async function saReplyCall(it, text, outcome, targetProjectId = null){
|
|
10431
|
+
const r = await saCall(it.ids, 'dismiss', { text, outcome, targetProjectId });
|
|
10424
10432
|
if (!r?.ok) throw new Error('reply failed');
|
|
10425
10433
|
return r.json().catch(() => null);
|
|
10426
10434
|
}
|
|
@@ -10527,7 +10535,7 @@ async function saSendReply(it, i, text){
|
|
|
10527
10535
|
try {
|
|
10528
10536
|
let body = await saReplyCall(it, workerText, 'auto');
|
|
10529
10537
|
if (body?.outcome === 'unsure') {
|
|
10530
|
-
it.inflight = false; it.rstatus = 'unsure'; it.rchoices = body
|
|
10538
|
+
it.inflight = false; it.rstatus = 'unsure'; it.rchoices = replyChoices(body);
|
|
10531
10539
|
saPaintCard(goalId);
|
|
10532
10540
|
const picked = await new Promise((resolve) => { it._routeResolve = resolve; });
|
|
10533
10541
|
it._routeResolve = null;
|
|
@@ -10537,7 +10545,9 @@ async function saSendReply(it, i, text){
|
|
|
10537
10545
|
saPaintCard(goalId); return;
|
|
10538
10546
|
}
|
|
10539
10547
|
it.inflight = true; it.rstatus = 'thinking'; saPaintCard(goalId);
|
|
10540
|
-
body =
|
|
10548
|
+
body = picked.startsWith('project:')
|
|
10549
|
+
? await saReplyCall(it, workerText, 'split', picked.slice('project:'.length))
|
|
10550
|
+
: await saReplyCall(it, workerText, picked);
|
|
10541
10551
|
}
|
|
10542
10552
|
it.inflight = false;
|
|
10543
10553
|
if (body?.outcome === 'answer') { // a question: the AI answers, the goal never moves
|
|
@@ -11270,8 +11280,6 @@ $('filein').addEventListener('change', () => {
|
|
|
11270
11280
|
for (const f of $('filein').files) uploadFile(f);
|
|
11271
11281
|
$('filein').value = '';
|
|
11272
11282
|
});
|
|
11273
|
-
// Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
|
|
11274
|
-
$('replyBindX').onclick = () => { state.composerDetachedGoal = state.selectedGoal; render(); };
|
|
11275
11283
|
const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
|
|
11276
11284
|
// Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
|
|
11277
11285
|
// conversation + composer (the sheet is an 88vw overlay — default-open buried
|
package/engine/lib.mjs
CHANGED
|
@@ -46,6 +46,19 @@ export function isCommandOnPath(cmd, { pathEnv = '', sep = ':', pathExt = '', ex
|
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// A directory mention becomes an execution target only when the person labels
|
|
50
|
+
// it as a working/development directory. Bare paths remain ordinary context so
|
|
51
|
+
// a worker is never silently moved just because a filename appeared in prose.
|
|
52
|
+
export function parseRequestedWorkspaceDir(text) {
|
|
53
|
+
const source = String(text ?? '');
|
|
54
|
+
const marker = /(?:working\s*directory|workdir|作業(?:ディレクトリ|場所)|開発場所)\s*(?:を|は|:|:|=|=)?\s*/i;
|
|
55
|
+
const found = source.match(marker);
|
|
56
|
+
if (!found) return null;
|
|
57
|
+
const rest = source.slice((found.index ?? 0) + found[0].length);
|
|
58
|
+
const path = rest.match(/\/(?:[A-Za-z0-9._~@%+\-]+\/)*[A-Za-z0-9._~@%+\-]+\/?/);
|
|
59
|
+
return path ? path[0].replace(/\/+$/, '') || '/' : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
// Galda makes small internal LLM calls (composer-intent classification, proof-card
|
|
50
63
|
// summaries, board Q&A). These prefer Claude Code + haiku (cheap + fast), but must
|
|
51
64
|
// still work on a Codex-only machine — so they run on whatever agent is available,
|
|
@@ -2458,6 +2471,55 @@ export function dismissGoal({ goalStatus, outcome = 'continue' } = {}) {
|
|
|
2458
2471
|
return { ok: true, outcome: 'continue', status: 'running', spawnsWorker: true };
|
|
2459
2472
|
}
|
|
2460
2473
|
|
|
2474
|
+
// `outcome:auto` is the contract used by every review reply box in the app.
|
|
2475
|
+
// Keep the routing decision in one place: the reply either asks a question,
|
|
2476
|
+
// requests a change, rescopes the ticket, starts separate work, or explicitly
|
|
2477
|
+
// remains unsure. Returning unsure is safe because the UI already presents
|
|
2478
|
+
// these exact choices without moving the goal.
|
|
2479
|
+
export const REPLY_INTENTS = [...REPLY_OUTCOMES, 'unsure'];
|
|
2480
|
+
export function buildReplyIntentPrompt({ goalText, reply, currentProject = null, projects = [] } = {}) {
|
|
2481
|
+
const projectLines = (projects ?? []).map((project) =>
|
|
2482
|
+
`- ${project.id}: ${project.name || project.id} | root=${project.dir || '(unknown)'} | scope=${project.note || '(not specified)'}`);
|
|
2483
|
+
return [
|
|
2484
|
+
'You route replies in Agent Manager: a person delegated a coding task, an AI did it, and it came back for review. The person has now replied.',
|
|
2485
|
+
'Decide what the reply ASKS FOR — exactly one of:',
|
|
2486
|
+
'- "continue": fix or change the work that was just done.',
|
|
2487
|
+
'- "rescope": stop and rethink; the request itself should now say something different.',
|
|
2488
|
+
'- "split": a separate piece of work that should not touch this one.',
|
|
2489
|
+
'- "answer": a question about the work. Nothing should be built or changed.',
|
|
2490
|
+
'- "unsure": you genuinely cannot tell. Prefer this over guessing.',
|
|
2491
|
+
'Judge the full meaning. A question followed by a requested change is "continue".',
|
|
2492
|
+
'Also decide which project must receive the requested work.',
|
|
2493
|
+
'- Keep targetProjectId equal to the current project for ordinary fixes to the reviewed work.',
|
|
2494
|
+
'- If the current project is design/docs/prototype-only and the reply asks to implement or launch the chosen design in the real product, select the matching production/code project.',
|
|
2495
|
+
'- Never infer a project that is not listed. If the target is ambiguous, use outcome "unsure" and targetProjectId null.',
|
|
2496
|
+
'Output ONLY one line of raw JSON: {"outcome":"continue|rescope|split|answer|unsure","targetProjectId":"listed-id-or-null"}.',
|
|
2497
|
+
'',
|
|
2498
|
+
`Current project: ${currentProject?.id || '(unknown)'}`,
|
|
2499
|
+
'Available projects:',
|
|
2500
|
+
...(projectLines.length ? projectLines : ['- (none)']),
|
|
2501
|
+
'',
|
|
2502
|
+
'The request that was worked on:',
|
|
2503
|
+
String(goalText ?? '').slice(0, 2000),
|
|
2504
|
+
'',
|
|
2505
|
+
'Their reply:',
|
|
2506
|
+
String(reply ?? '').slice(0, 4000),
|
|
2507
|
+
].join('\n');
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
export function parseReplyIntent(raw) {
|
|
2511
|
+
const unread = { outcome: 'unsure', read: false };
|
|
2512
|
+
if (!raw || typeof raw !== 'string') return unread;
|
|
2513
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
2514
|
+
if (!m) return unread;
|
|
2515
|
+
let obj;
|
|
2516
|
+
try { obj = JSON.parse(m[0]); } catch { return unread; }
|
|
2517
|
+
if (!REPLY_INTENTS.includes(obj?.outcome)) return unread;
|
|
2518
|
+
const targetProjectId = typeof obj.targetProjectId === 'string' && obj.targetProjectId.trim()
|
|
2519
|
+
? obj.targetProjectId.trim() : null;
|
|
2520
|
+
return { outcome: obj.outcome, targetProjectId, read: true };
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2461
2523
|
// 宛先(docs/design/ONE-CONVERSATION.md §1.2)
|
|
2462
2524
|
//
|
|
2463
2525
|
// チャットで「#12 の件だけど」と書けたら、そのチケット宛て。**決定論で、我々の推測は
|
|
@@ -2904,12 +2966,10 @@ export function buildEphemeralSeedProjects(cwd) {
|
|
|
2904
2966
|
// goal/task written once (e.g. a memo the user wants to survive a restart)
|
|
2905
2967
|
// is never lost: later lines for the same id override earlier ones (last
|
|
2906
2968
|
// write wins), and a goal write with `deleted:true` removes it. Mid-flight
|
|
2907
|
-
// work
|
|
2908
|
-
//
|
|
2909
|
-
//
|
|
2910
|
-
//
|
|
2911
|
-
// the normal completion path, even if the crash happened before the PR got
|
|
2912
|
-
// created (the caller re-attempts createGoalPR for those on restart).
|
|
2969
|
+
// work gets one bounded automatic recovery: a 'running' task becomes 'queued'
|
|
2970
|
+
// once and resumes from the files/session already on disk. A second interrupted
|
|
2971
|
+
// boot stays visible for human retry so a crash loop cannot relaunch forever.
|
|
2972
|
+
// A 'planning' goal still goes back to 'stacked'.
|
|
2913
2973
|
// `reviewDefinitions` (projectId -> definition, as saved by
|
|
2914
2974
|
// validateReviewDefinition) lets that settling honor each project's own
|
|
2915
2975
|
// review definition (task 45); a project with none configured falls back to
|
|
@@ -2926,7 +2986,21 @@ export function replayQueueLog(rawText, reviewDefinitions = {}) {
|
|
|
2926
2986
|
} catch { /* skip a corrupt line rather than lose the whole log */ }
|
|
2927
2987
|
}
|
|
2928
2988
|
for (const t of tasks) {
|
|
2929
|
-
if (t.status
|
|
2989
|
+
if (t.status !== 'running') continue;
|
|
2990
|
+
// A Manager restart is an infrastructure interruption, not a request for
|
|
2991
|
+
// human judgment. Resume the same task once from the files/session already
|
|
2992
|
+
// on disk. The bounded counter prevents a crash-looping task from being
|
|
2993
|
+
// relaunched forever; a second interrupted boot remains visibly retryable.
|
|
2994
|
+
const recoveries = Number(t.restartRecoveries ?? 0);
|
|
2995
|
+
if (recoveries < 1) {
|
|
2996
|
+
t.status = 'queued';
|
|
2997
|
+
t.restartRecoveries = recoveries + 1;
|
|
2998
|
+
t.recoveredFromRestart = true;
|
|
2999
|
+
t.startedAt = null;
|
|
3000
|
+
t.finishedAt = null;
|
|
3001
|
+
} else {
|
|
3002
|
+
t.status = 'interrupted';
|
|
3003
|
+
}
|
|
2930
3004
|
}
|
|
2931
3005
|
for (const g of goals) {
|
|
2932
3006
|
if (g.status === 'planning') g.status = 'stacked';
|
|
@@ -3187,6 +3261,14 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
|
|
|
3187
3261
|
return [
|
|
3188
3262
|
skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
|
|
3189
3263
|
`You are a ${displayName} worker managed by "Manager for AI". Do the request below inside this project directory.`,
|
|
3264
|
+
task?.projectContext ? [
|
|
3265
|
+
'PROJECT BOUNDARY (source of truth):',
|
|
3266
|
+
`- Project: ${task.projectContext.name || task.projectContext.id}`,
|
|
3267
|
+
`- Root: ${task.projectContext.dir}`,
|
|
3268
|
+
task.projectContext.note ? `- Scope/note: ${task.projectContext.note}` : '',
|
|
3269
|
+
'- Your edits and claims apply only to this project. Never call a design/docs/prototype change a production implementation.',
|
|
3270
|
+
'- If the request requires another project, do not pretend it was implemented here; report the exact project mismatch.',
|
|
3271
|
+
].filter(Boolean).join('\n') : '',
|
|
3190
3272
|
goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
|
|
3191
3273
|
'',
|
|
3192
3274
|
// The user's own words, verbatim and in full, are the source of truth.
|
|
@@ -3404,7 +3486,9 @@ export function parseRunDeclaration(text) {
|
|
|
3404
3486
|
const compare = parseComparePair(raw.compare);
|
|
3405
3487
|
const title = str(raw.title, 60);
|
|
3406
3488
|
// Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
|
|
3407
|
-
if (!cmd && !url && !file && !compare && !title)
|
|
3489
|
+
if (!cmd && !url && !file && !compare && !title) {
|
|
3490
|
+
return runDeclError('no-open-target', 'declaration contains no command, URL, artifact file, comparison, or title');
|
|
3491
|
+
}
|
|
3408
3492
|
// Only http(s): the button opens this in the user's browser. A file:// (or
|
|
3409
3493
|
// any other scheme) is a worker mistake with a specific fix — declare `file`
|
|
3410
3494
|
// instead — not a JSON parse failure, so it gets its own reason.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
export const REQUIREMENT_MODEL_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
const CLAIM_STATUSES = new Set(['addressed', 'partial', 'not-addressed']);
|
|
4
|
+
const COVERAGE_STATUSES = new Set(['unverified', 'verified', 'failed', 'not-applicable']);
|
|
5
|
+
|
|
6
|
+
function cleanText(value) {
|
|
7
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stablePart(value) {
|
|
11
|
+
return String(value ?? 'unknown').replace(/[^a-zA-Z0-9_-]+/g, '-');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function requirementId(goalId, messageId) {
|
|
15
|
+
return `req-${stablePart(goalId)}-${stablePart(messageId)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function attemptId(task) {
|
|
19
|
+
return cleanText(task?.attemptId) || `attempt-task-${stablePart(task?.id)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sourceForTask(task) {
|
|
23
|
+
if (!task?.reply) return 'user';
|
|
24
|
+
if (task.replyFrom === 'you') return 'user';
|
|
25
|
+
if (task.replyFrom === 'manager') return 'manager-policy';
|
|
26
|
+
return 'legacy-unknown';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function messageText(task) {
|
|
30
|
+
return cleanText(task?.reply ? task.detail : (task?.detail || task?.title));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeRequirement(value) {
|
|
34
|
+
const id = cleanText(value?.id);
|
|
35
|
+
const text = cleanText(value?.text);
|
|
36
|
+
if (!id || !text) return null;
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
source: cleanText(value.source) || 'legacy-unknown',
|
|
40
|
+
messageId: value.messageId == null ? null : String(value.messageId),
|
|
41
|
+
text,
|
|
42
|
+
...(Array.isArray(value.span) && value.span.length === 2 ? { span: value.span } : {}),
|
|
43
|
+
...(value.derivedFrom ? { derivedFrom: value.derivedFrom } : {}),
|
|
44
|
+
...(value.supersedes ? { supersedes: value.supersedes } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeAttempt(value) {
|
|
49
|
+
const id = cleanText(value?.id);
|
|
50
|
+
if (!id) return null;
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
taskId: value.taskId ?? null,
|
|
54
|
+
status: cleanText(value.status) || 'unknown',
|
|
55
|
+
createdAt: value.createdAt ?? null,
|
|
56
|
+
finishedAt: value.finishedAt ?? null,
|
|
57
|
+
sessionId: value.sessionId ?? null,
|
|
58
|
+
worktree: value.worktree ?? null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeEvidence(value) {
|
|
63
|
+
const requirementId = cleanText(value?.requirementId);
|
|
64
|
+
const attemptId = cleanText(value?.attemptId);
|
|
65
|
+
if (!requirementId || !attemptId) return null;
|
|
66
|
+
const claim = CLAIM_STATUSES.has(value.claim) ? value.claim : 'not-addressed';
|
|
67
|
+
const coverage = COVERAGE_STATUSES.has(value.coverage) ? value.coverage : 'unverified';
|
|
68
|
+
return {
|
|
69
|
+
requirementId,
|
|
70
|
+
attemptId,
|
|
71
|
+
taskId: value.taskId ?? null,
|
|
72
|
+
claim,
|
|
73
|
+
coverage,
|
|
74
|
+
evidence: cleanText(value.evidence),
|
|
75
|
+
files: [...new Set(Array.isArray(value.files) ? value.files.filter((item) => typeof item === 'string' && item) : [])],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function taskRequirementIds(task, requirements, legacyMap) {
|
|
80
|
+
const explicit = Array.isArray(task?.activeRequirementIds)
|
|
81
|
+
? task.activeRequirementIds.filter((id) => requirements.some((requirement) => requirement.id === id))
|
|
82
|
+
: [];
|
|
83
|
+
if (explicit.length) return [...new Set(explicit)];
|
|
84
|
+
|
|
85
|
+
const own = legacyMap[`task-${task?.id}`];
|
|
86
|
+
if (own) return [own];
|
|
87
|
+
return requirements.length ? [requirements[0].id] : [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Convert legacy goal/task records into the canonical requirement model.
|
|
92
|
+
* This function is pure and idempotent. It never removes legacy fields, so a
|
|
93
|
+
* rollback can continue reading the original records.
|
|
94
|
+
*/
|
|
95
|
+
export function migrateRequirementModel(goal, goalTasks = []) {
|
|
96
|
+
const requirements = (Array.isArray(goal?.requirements) ? goal.requirements : [])
|
|
97
|
+
.map(normalizeRequirement).filter(Boolean);
|
|
98
|
+
const attempts = (Array.isArray(goal?.attempts) ? goal.attempts : [])
|
|
99
|
+
.map(normalizeAttempt).filter(Boolean);
|
|
100
|
+
const evidence = (Array.isArray(goal?.requirementEvidence) ? goal.requirementEvidence : [])
|
|
101
|
+
.map(normalizeEvidence).filter(Boolean);
|
|
102
|
+
const legacyRequirementMap = { ...(goal?.legacyRequirementMap || {}) };
|
|
103
|
+
|
|
104
|
+
if (!requirements.length && cleanText(goal?.text)) {
|
|
105
|
+
const id = requirementId(goal.id, 'goal');
|
|
106
|
+
requirements.push({ id, source: 'user', messageId: `goal-${goal.id}`, text: cleanText(goal.text) });
|
|
107
|
+
legacyRequirementMap.goal = id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const task of goalTasks) {
|
|
111
|
+
const text = messageText(task);
|
|
112
|
+
const shouldCreateRequirement = text && (task.reply ? sourceForTask(task) !== 'manager-policy' : !requirements.length);
|
|
113
|
+
if (shouldCreateRequirement) {
|
|
114
|
+
const legacyId = `task-${task.id}`;
|
|
115
|
+
const id = legacyRequirementMap[legacyId] || requirementId(goal?.id, legacyId);
|
|
116
|
+
if (!requirements.some((requirement) => requirement.id === id)) {
|
|
117
|
+
requirements.push({ id, source: sourceForTask(task), messageId: legacyId, text });
|
|
118
|
+
}
|
|
119
|
+
legacyRequirementMap[legacyId] = id;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const id = attemptId(task);
|
|
123
|
+
if (!attempts.some((attempt) => attempt.id === id)) {
|
|
124
|
+
attempts.push(normalizeAttempt({
|
|
125
|
+
id,
|
|
126
|
+
taskId: task.id,
|
|
127
|
+
status: task.status,
|
|
128
|
+
createdAt: task.createdAt,
|
|
129
|
+
finishedAt: task.finishedAt,
|
|
130
|
+
sessionId: task.sessionId ?? task.agentSessionId,
|
|
131
|
+
worktree: task.worktree,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const requirementIds = taskRequirementIds(task, requirements, legacyRequirementMap);
|
|
136
|
+
for (const claim of task?.requirementEvidence?.requirements ?? []) {
|
|
137
|
+
const mappedId = legacyRequirementMap[claim.id] || (requirements.some((requirement) => requirement.id === claim.id) ? claim.id : null);
|
|
138
|
+
const targetIds = mappedId ? [mappedId] : requirementIds;
|
|
139
|
+
for (const targetId of targetIds) {
|
|
140
|
+
const row = normalizeEvidence({
|
|
141
|
+
requirementId: targetId,
|
|
142
|
+
attemptId: id,
|
|
143
|
+
taskId: task.id,
|
|
144
|
+
claim: claim.status,
|
|
145
|
+
coverage: 'unverified',
|
|
146
|
+
evidence: claim.evidence,
|
|
147
|
+
files: claim.files,
|
|
148
|
+
});
|
|
149
|
+
if (row && !evidence.some((item) => item.requirementId === row.requirementId && item.attemptId === row.attemptId && item.taskId === row.taskId)) {
|
|
150
|
+
evidence.push(row);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tasks = goalTasks.map((task) => ({
|
|
157
|
+
...task,
|
|
158
|
+
activeRequirementIds: taskRequirementIds(task, requirements, legacyRequirementMap),
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
goal: {
|
|
163
|
+
...goal,
|
|
164
|
+
requirementModelVersion: REQUIREMENT_MODEL_VERSION,
|
|
165
|
+
requirements,
|
|
166
|
+
attempts,
|
|
167
|
+
requirementEvidence: evidence,
|
|
168
|
+
legacyRequirementMap,
|
|
169
|
+
},
|
|
170
|
+
tasks,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function validateRequirementModel(goal, goalTasks = []) {
|
|
175
|
+
const errors = [];
|
|
176
|
+
const requirementIds = new Set();
|
|
177
|
+
const attemptIds = new Set();
|
|
178
|
+
for (const requirement of goal?.requirements ?? []) {
|
|
179
|
+
if (!requirement?.id || requirementIds.has(requirement.id)) errors.push(`invalid requirement id: ${requirement?.id ?? ''}`);
|
|
180
|
+
requirementIds.add(requirement?.id);
|
|
181
|
+
}
|
|
182
|
+
for (const attempt of goal?.attempts ?? []) {
|
|
183
|
+
if (!attempt?.id || attemptIds.has(attempt.id)) errors.push(`invalid attempt id: ${attempt?.id ?? ''}`);
|
|
184
|
+
attemptIds.add(attempt?.id);
|
|
185
|
+
}
|
|
186
|
+
for (const task of goalTasks) {
|
|
187
|
+
for (const id of task?.activeRequirementIds ?? []) {
|
|
188
|
+
if (!requirementIds.has(id)) errors.push(`task ${task.id} references unknown requirement ${id}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const row of goal?.requirementEvidence ?? []) {
|
|
192
|
+
if (!requirementIds.has(row?.requirementId)) errors.push(`evidence references unknown requirement ${row?.requirementId ?? ''}`);
|
|
193
|
+
if (!attemptIds.has(row?.attemptId)) errors.push(`evidence references unknown attempt ${row?.attemptId ?? ''}`);
|
|
194
|
+
}
|
|
195
|
+
return { ok: errors.length === 0, errors };
|
|
196
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -20,8 +20,8 @@ import { resolve, dirname, join, basename } from 'node:path';
|
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
|
-
import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } 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';
|
|
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, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
|
|
25
25
|
import { createSerialQueue } from './lib.mjs';
|
|
26
26
|
import { parseRequirementEvidence, unmappedChangedFiles } from './lib.mjs';
|
|
27
27
|
import { isPrClosed } from './lib.mjs';
|
|
@@ -1524,6 +1524,27 @@ function removeGoalWorkDir(goal, project) {
|
|
|
1524
1524
|
try { const repoRoot = gitSync(project.dir, ['rev-parse', '--show-toplevel']); gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
|
|
1525
1525
|
}
|
|
1526
1526
|
|
|
1527
|
+
function projectForGoal(goal, project) {
|
|
1528
|
+
return goal?.workspaceRoot ? { ...project, dir: goal.workspaceRoot } : project;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function rebindGoalWorkspace(goal, project, requestedDir) {
|
|
1532
|
+
const abs = resolve(requestedDir);
|
|
1533
|
+
if (!existsSync(abs)) return { ok: false, error: `Working directory is unavailable: ${abs}. Reconnect or choose the folder before starting work.` };
|
|
1534
|
+
try { if (!statSync(abs).isDirectory()) return { ok: false, error: `Working directory is not a folder: ${abs}` }; } catch { return { ok: false, error: `Working directory cannot be read: ${abs}` }; }
|
|
1535
|
+
if (!isGitRepo(abs)) return { ok: false, error: `Working directory is not a Git repository: ${abs}` };
|
|
1536
|
+
removeGoalWorkDir(goal, project);
|
|
1537
|
+
goal.workspaceRoot = abs;
|
|
1538
|
+
// Claude Code's sandbox is fixed when a session starts. Never resume a
|
|
1539
|
+
// session that was launched from the previous project directory.
|
|
1540
|
+
goal.sessions = {};
|
|
1541
|
+
goal.sessionId = null;
|
|
1542
|
+
goal.sessionAgent = null;
|
|
1543
|
+
goal.contextHandoff = null;
|
|
1544
|
+
saveGoal(goal);
|
|
1545
|
+
return { ok: true, dir: abs };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1527
1548
|
// External commit sync: pick up commits made outside this Manager (Claude
|
|
1528
1549
|
// Code sessions run directly in the terminal, manual commits, other tools)
|
|
1529
1550
|
// so they show up alongside Manager-run tasks for the same project. The
|
|
@@ -2288,13 +2309,15 @@ function pauseGoalForApproval(goal, task, request) {
|
|
|
2288
2309
|
//
|
|
2289
2310
|
// 設定は親から引き継ぐ(プロジェクト・エージェント・モデル・effort)。別件とはいえ
|
|
2290
2311
|
// 同じ場所で同じ人が続けている作業なので、ここで選び直させる理由が無い。
|
|
2291
|
-
function createSplitGoal(parent, text, identity) {
|
|
2312
|
+
function createSplitGoal(parent, text, identity, targetProjectId = parent.projectId) {
|
|
2313
|
+
const targetProject = projects.find((project) => project.id === targetProjectId);
|
|
2314
|
+
if (!targetProject) throw new Error(`unknown target project: ${targetProjectId}`);
|
|
2292
2315
|
const goal = {
|
|
2293
|
-
id: nextId++, projectId:
|
|
2294
|
-
wantsPR: resolveWantsPR('auto', text, getReviewDefinition(
|
|
2316
|
+
id: nextId++, projectId: targetProjectId, text: text.slice(0, 8000), status: 'planning',
|
|
2317
|
+
wantsPR: resolveWantsPR('auto', text, getReviewDefinition(targetProjectId).defaultWantsPR),
|
|
2295
2318
|
model: parent.model, effort: parent.effort, agent: parent.agent, mode: 'auto',
|
|
2296
2319
|
skill: null, images: [], source: resolveGoalSource('app'), sourceRef: null,
|
|
2297
|
-
executionPlan: buildExecutionPlan({ projectId:
|
|
2320
|
+
executionPlan: buildExecutionPlan({ projectId: targetProjectId, text, previousSession: false }),
|
|
2298
2321
|
plan: null, pr: undefined, createdAt: new Date().toISOString(),
|
|
2299
2322
|
// 片方向の印。表示は app(新しいカード「#N の会話から生まれました」/元のカード
|
|
2300
2323
|
// 「→ #M を切り出しました」)。engine は数字を1つ持つだけ。
|
|
@@ -2444,6 +2467,13 @@ function readRunDeclaration(task, workDir) {
|
|
|
2444
2467
|
// never broken) and wrote the exact same file:// url a second time. `run` is
|
|
2445
2468
|
// `{ error, message }` here, distinct from the `null` case below.
|
|
2446
2469
|
if (run && run.error) {
|
|
2470
|
+
// Valid JSON with only note/check means there is simply nothing to open.
|
|
2471
|
+
// That is common for explanation-only follow-ups and must not manufacture
|
|
2472
|
+
// an "unreadable record" error in Review.
|
|
2473
|
+
if (run.error === 'no-open-target') {
|
|
2474
|
+
sendAct(task, 'run declaration ignored — this result has nothing to open.');
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
2447
2477
|
task.runDeclError = { reason: run.error, message: run.message, snippet: String(raw ?? '').slice(0, 200) };
|
|
2448
2478
|
sendAct(task, `run declaration ignored — ${run.message}`);
|
|
2449
2479
|
return;
|
|
@@ -2673,6 +2703,7 @@ async function startPreview(task) {
|
|
|
2673
2703
|
async function runTask(task) {
|
|
2674
2704
|
const project = projects.find((p) => p.id === task.projectId);
|
|
2675
2705
|
const goal = goals.find((g) => g.id === task.goalId);
|
|
2706
|
+
task.projectContext = project ? { id: project.id, name: project.name, dir: project.dir, note: project.note } : null;
|
|
2676
2707
|
task.status = 'running'; task.startedAt = new Date().toISOString();
|
|
2677
2708
|
// The goal's "started" clock is set by the first task that actually runs — the
|
|
2678
2709
|
// signal the board uses to read a 'running' goal as DOING rather than QUEUED
|
|
@@ -2687,8 +2718,9 @@ async function runTask(task) {
|
|
|
2687
2718
|
|
|
2688
2719
|
// The workspace mode is chosen at goal creation and then held through every
|
|
2689
2720
|
// worker/review/PR step.
|
|
2690
|
-
const
|
|
2691
|
-
const
|
|
2721
|
+
const effectiveProject = goal ? projectForGoal(goal, project) : project;
|
|
2722
|
+
const workDir = goal ? goalWorkDir(goal, effectiveProject) : effectiveProject.dir;
|
|
2723
|
+
const wproject = { ...effectiveProject, dir: workDir };
|
|
2692
2724
|
const workspaceMode = resolveWorkspaceMode(task.workspaceMode ?? goal?.workspaceMode ?? goal?.worktree);
|
|
2693
2725
|
sendProcessAct(task, workspaceMode === 'direct-workspace'
|
|
2694
2726
|
? `Using direct workspace: ${workDir}`
|
|
@@ -2707,7 +2739,11 @@ async function runTask(task) {
|
|
|
2707
2739
|
const planMode = goal?.mode === 'plan' && goal?.planPhase !== 'executing';
|
|
2708
2740
|
const permissionMode = permissionModeFor(planMode ? 'plan' : (task.mode ?? goal?.mode));
|
|
2709
2741
|
const entryUrl = (task.passCondition && !planMode) ? entryUrlFor(goal, wproject) : null;
|
|
2710
|
-
|
|
2742
|
+
// One continuation is reserved for a worker that reaches the infrastructure
|
|
2743
|
+
// time cap. Long research/report tasks commonly need just over one window;
|
|
2744
|
+
// resume their saved session automatically instead of asking the customer to
|
|
2745
|
+
// discover and press Retry. Normal failures still stop after one attempt.
|
|
2746
|
+
const maxAttempts = entryUrl ? 3 : 2;
|
|
2711
2747
|
const t0 = Date.now();
|
|
2712
2748
|
|
|
2713
2749
|
// Thread replies resume the goal's Claude Code session — same context,
|
|
@@ -2872,6 +2908,10 @@ async function runTask(task) {
|
|
|
2872
2908
|
if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
|
|
2873
2909
|
approval = parseApprovalRequest(result);
|
|
2874
2910
|
if (approval) break;
|
|
2911
|
+
if (timedOut && attempt < maxAttempts) {
|
|
2912
|
+
sendProcessAct(task, 'Run limit reached — automatically continuing the same task from its saved session.');
|
|
2913
|
+
continue;
|
|
2914
|
+
}
|
|
2875
2915
|
if (!entryUrl) break;
|
|
2876
2916
|
try {
|
|
2877
2917
|
if (!verify) {
|
|
@@ -5224,6 +5264,13 @@ const server = createServer(async (req, res) => {
|
|
|
5224
5264
|
if (!text?.trim()) return json(res, 400, { error: 'text required' });
|
|
5225
5265
|
const worker = applyReplyWorker(goal, requested);
|
|
5226
5266
|
if (!worker.ok) return json(res, 409, { error: worker.error });
|
|
5267
|
+
const requestedDir = parseRequestedWorkspaceDir(text);
|
|
5268
|
+
if (requestedDir) {
|
|
5269
|
+
const project = projects.find((p) => p.id === goal.projectId);
|
|
5270
|
+
if (!project) return json(res, 404, { error: 'project not found' });
|
|
5271
|
+
const rebound = rebindGoalWorkspace(goal, project, requestedDir);
|
|
5272
|
+
if (!rebound.ok) return json(res, 409, rebound);
|
|
5273
|
+
}
|
|
5227
5274
|
// Reply picks a parked goal back up into the work queue. 'review' MUST be
|
|
5228
5275
|
// here: a completed goal awaiting your approve/reject is 'review', and a
|
|
5229
5276
|
// reply to it means "not done — keep working" (Masa 2026-07-24: replied to
|
|
@@ -5447,6 +5494,30 @@ const server = createServer(async (req, res) => {
|
|
|
5447
5494
|
// 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
|
|
5448
5495
|
//
|
|
5449
5496
|
// 答えるのは**作業した AI 本人**=その agent の session を resume する。状況一覧しか
|
|
5497
|
+
// Resolve the app's `outcome:auto` contract before changing goal state. The
|
|
5498
|
+
// utility call is classification-only; the goal's own agent/session still
|
|
5499
|
+
// writes an answer or performs the requested change. If routing is unavailable,
|
|
5500
|
+
// return unsure so a customer chooses explicitly instead of accidentally
|
|
5501
|
+
// starting a ten-minute implementation worker for a question.
|
|
5502
|
+
async function judgeReplyIntent(goal, reply) {
|
|
5503
|
+
try {
|
|
5504
|
+
const currentProject = projects.find((project) => project.id === goal.projectId) ?? null;
|
|
5505
|
+
const r = await runUtility({
|
|
5506
|
+
prompt: buildReplyIntentPrompt({ goalText: goal.text, reply, currentProject, projects }),
|
|
5507
|
+
cwd: ROOT,
|
|
5508
|
+
tools: 'Read',
|
|
5509
|
+
});
|
|
5510
|
+
const parsed = parseReplyIntent(r.result);
|
|
5511
|
+
if (!parsed.read) return { outcome: 'unsure', targetProjectId: null };
|
|
5512
|
+
if (parsed.targetProjectId && !projects.some((project) => project.id === parsed.targetProjectId)) {
|
|
5513
|
+
return { outcome: 'unsure', targetProjectId: null };
|
|
5514
|
+
}
|
|
5515
|
+
return { outcome: parsed.outcome, targetProjectId: parsed.targetProjectId };
|
|
5516
|
+
} catch {
|
|
5517
|
+
return { outcome: 'unsure', targetProjectId: null };
|
|
5518
|
+
}
|
|
5519
|
+
}
|
|
5520
|
+
|
|
5450
5521
|
// 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
|
|
5451
5522
|
// コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
|
|
5452
5523
|
// (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
|
|
@@ -5497,20 +5568,29 @@ async function answerGoalQuestion(goal, question) {
|
|
|
5497
5568
|
let body = '';
|
|
5498
5569
|
req.on('data', (d) => { body += d; });
|
|
5499
5570
|
req.on('end', async () => {
|
|
5500
|
-
let text = '', outcome = 'continue', requested = {};
|
|
5571
|
+
let text = '', outcome = 'continue', requested = {}, auto = false;
|
|
5501
5572
|
try {
|
|
5502
5573
|
requested = JSON.parse(body || '{}');
|
|
5503
5574
|
text = String(requested.text ?? '').trim();
|
|
5504
5575
|
if (REPLY_OUTCOMES.includes(requested.outcome)) outcome = requested.outcome;
|
|
5576
|
+
auto = requested.outcome === 'auto';
|
|
5505
5577
|
} catch {}
|
|
5506
5578
|
const worker = applyReplyWorker(goal, requested);
|
|
5507
5579
|
if (!worker.ok) return json(res, 409, { error: worker.error });
|
|
5508
5580
|
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5581
|
+
if (auto) {
|
|
5582
|
+
if (!text) return json(res, 400, { error: 'text required' });
|
|
5583
|
+
const routed = await judgeReplyIntent(goal, text);
|
|
5584
|
+
outcome = routed.outcome;
|
|
5585
|
+
requested.targetProjectId = routed.targetProjectId;
|
|
5586
|
+
if (outcome === 'unsure') return json(res, 200, {
|
|
5587
|
+
goal,
|
|
5588
|
+
outcome,
|
|
5589
|
+
choices: REPLY_OUTCOMES,
|
|
5590
|
+
projectChoices: projects.map((project) => ({ id: project.id, name: project.name, current: project.id === goal.projectId })),
|
|
5591
|
+
});
|
|
5592
|
+
if (requested.targetProjectId && requested.targetProjectId !== goal.projectId) outcome = 'split';
|
|
5593
|
+
}
|
|
5514
5594
|
// 返事の行き先4種 (ONE-CONVERSATION.md §1.3). 既定は今までどおり continue なので、
|
|
5515
5595
|
// outcome を送ってこない今の UI の挙動は1ミリも変わらない。
|
|
5516
5596
|
const result = dismissGoal({ goalStatus: goal.status, outcome });
|
|
@@ -5523,8 +5603,23 @@ async function answerGoalQuestion(goal, question) {
|
|
|
5523
5603
|
const gate = await requireEntitlementGate(identityFor(req));
|
|
5524
5604
|
if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
|
|
5525
5605
|
addGoalMessage(goal, { from: 'you', via: 'review', text });
|
|
5526
|
-
const
|
|
5527
|
-
return json(res,
|
|
5606
|
+
const targetProjectId = requested.targetProjectId || goal.projectId;
|
|
5607
|
+
if (!projects.some((project) => project.id === targetProjectId)) return json(res, 400, { error: 'unknown target project' });
|
|
5608
|
+
const crossProject = targetProjectId !== goal.projectId;
|
|
5609
|
+
const latest = [...tasks].reverse().find((task) => task.goalId === goal.id && task.result);
|
|
5610
|
+
const handoffText = crossProject ? [
|
|
5611
|
+
`[Cross-project implementation handoff from #${goal.id}]`,
|
|
5612
|
+
`Original request: ${goal.text}`,
|
|
5613
|
+
goal.reviewSummary?.summary ? `Reviewed result: ${goal.reviewSummary.summary}` : '',
|
|
5614
|
+
latest?.result ? `Latest worker result: ${String(latest.result).slice(0, 3000)}` : '',
|
|
5615
|
+
`User's implementation request: ${text}`,
|
|
5616
|
+
].filter(Boolean).join('\n\n') : text;
|
|
5617
|
+
const born = createSplitGoal(goal, handoffText, identityFor(req), targetProjectId);
|
|
5618
|
+
if (crossProject) {
|
|
5619
|
+
const target = projects.find((project) => project.id === targetProjectId);
|
|
5620
|
+
addGoalMessage(goal, { from: 'manager', via: 'review', text: `Implementation was routed to ${target?.name || targetProjectId} (#${born.id}) instead of editing this project's review.` });
|
|
5621
|
+
}
|
|
5622
|
+
return json(res, 200, { goal, outcome, splitInto: born.id, born, targetProjectId });
|
|
5528
5623
|
}
|
|
5529
5624
|
|
|
5530
5625
|
// answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
|
|
@@ -5735,6 +5830,13 @@ server.on('error', (e) => {
|
|
|
5735
5830
|
});
|
|
5736
5831
|
|
|
5737
5832
|
server.listen(PORT, '127.0.0.1', () => {
|
|
5833
|
+
// Persist the bounded recovery counter before pumping. If auth is offline
|
|
5834
|
+
// and the machine restarts again while the task is still queued, the ledger
|
|
5835
|
+
// must remember that one automatic recovery was already attempted.
|
|
5836
|
+
for (const task of tasks.filter((t) => t.recoveredFromRestart)) {
|
|
5837
|
+
delete task.recoveredFromRestart;
|
|
5838
|
+
saveTask(task);
|
|
5839
|
+
}
|
|
5738
5840
|
PORT = server.address().port; // the OS-assigned one when MANAGER_PORT=0
|
|
5739
5841
|
try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
|
|
5740
5842
|
console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
|
package/package.json
CHANGED