@galda/cli 0.10.90 → 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 +37 -11
- package/engine/lib.mjs +32 -7
- package/engine/requirement-model.mjs +196 -0
- package/engine/server.mjs +48 -4
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -6236,13 +6236,14 @@ function summarizeAttention(goal, tasks){
|
|
|
6236
6236
|
decision = ja
|
|
6237
6237
|
? 'worker に失敗したテスト出力を確認させ、回帰を修正して、プロジェクトのテストを再実行するよう指示してください。'
|
|
6238
6238
|
: 'Ask the worker to inspect the failing test output, fix the regression, and rerun the project tests.';
|
|
6239
|
-
} 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;
|
|
6240
6241
|
happened = ja
|
|
6241
|
-
? `必要だったこと: ${need}。${taskName ? `「${taskName}」の作業中に、` : ''}
|
|
6242
|
-
: `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)}`}.`;
|
|
6243
6244
|
decision = ja
|
|
6244
|
-
? '
|
|
6245
|
-
: 'Resume
|
|
6245
|
+
? '依頼を言い直したり範囲を狭めたりせず、そのまま Resume できます。既存の作業ファイルから続行します。'
|
|
6246
|
+
: 'Resume the same request without rewording or narrowing it. Work continues from the existing files.';
|
|
6246
6247
|
} else if (/blocked/i.test(goal.status || '') || compact) {
|
|
6247
6248
|
happened = ja
|
|
6248
6249
|
? `必要だったこと: ${need}。Review に進む前に停止しました${compact ? `: ${compact.slice(0, 160)}` : ''}。`
|
|
@@ -6505,7 +6506,12 @@ function renderFsRail(){
|
|
|
6505
6506
|
const running = pgoals.some((g) => fsBoardBucket(g, ptasks) === 'doing'
|
|
6506
6507
|
&& !(g.status === 'blocked' && g.blocked?.kind === 'paused'));
|
|
6507
6508
|
const td = ptasks.filter((t) => ['running', 'queued'].includes(t.status)).length;
|
|
6508
|
-
|
|
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;
|
|
6509
6515
|
const pcount = `<span class="pcount" title="${rev} review${rev === 1 ? '' : 's'} ready · ${td} to-do">`
|
|
6510
6516
|
+ `<span class="pc-item rev${rev ? '' : ' zero'}">${FS_ICO_SCAN}${rev}</span>`
|
|
6511
6517
|
+ `<span class="pc-item td">${FS_ICO_TD}${td}</span></span>`;
|
|
@@ -7249,7 +7255,9 @@ function taskErrorMeta(t, goal){
|
|
|
7249
7255
|
// Reclassify interrupted tasks from their concrete exit text instead of
|
|
7250
7256
|
// trusting stale persisted metadata. Older records labelled an external
|
|
7251
7257
|
// SIGTERM/restart as an Agent failure and permanently carried a Codex switch.
|
|
7252
|
-
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);
|
|
7253
7261
|
const raw = t.status === 'interrupted' ? inferred : (t.errorClass || inferred);
|
|
7254
7262
|
const meta = { ...raw, actionIds: (raw.actionIds || []).filter((id) => !['run-with-codex', 'run-with-claude'].includes(id)) };
|
|
7255
7263
|
const lang = failureLang(), C = FAILURE_COPY[lang];
|
|
@@ -9246,7 +9254,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
9246
9254
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
9247
9255
|
// settles it (see saSync's "returned to review" reset).
|
|
9248
9256
|
// ============================================================================
|
|
9249
|
-
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 };
|
|
9250
9258
|
function saReplyAttachmentsHtml(goalId){
|
|
9251
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('');
|
|
9252
9260
|
}
|
|
@@ -9936,7 +9944,9 @@ function saItemHtml(it, i){
|
|
|
9936
9944
|
const tests = it.testResult?.ran
|
|
9937
9945
|
? (it.testResult.ok ? 'Verification completed before the interruption.' : 'Verification remains incomplete or failed.')
|
|
9938
9946
|
: 'Verification has not completed yet.';
|
|
9939
|
-
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);
|
|
9940
9950
|
return `<div class="rinfra"><p class="rbody v"><b>Interrupted — resume available</b><br>${esc(reason)}</p>`
|
|
9941
9951
|
+ (last ? `<p class="rbody v">Last recorded step: ${esc(String(last).slice(0, 220))}</p>` : '')
|
|
9942
9952
|
+ (files.length ? `<p class="rbody v">Changed so far: ${esc(files.join(' · '))}</p>` : '')
|
|
@@ -10220,6 +10230,9 @@ function saRender(){
|
|
|
10220
10230
|
const previousBody = S.querySelector('.sa-body');
|
|
10221
10231
|
const previousScrollTop = previousBody?.scrollTop ?? 0;
|
|
10222
10232
|
const restoreScroll = Boolean(previousBody) && SA.focusGid == null;
|
|
10233
|
+
const focusedReplyGoal = document.activeElement?.matches?.('#seeall .sa-chat')
|
|
10234
|
+
? document.activeElement.dataset.goal
|
|
10235
|
+
: null;
|
|
10223
10236
|
const pending = SA.items.filter((x) => x.st === 0);
|
|
10224
10237
|
const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
|
|
10225
10238
|
// Solo mode = a single "Needs you" goal opened as this card. It is not a review batch, so the
|
|
@@ -10230,8 +10243,16 @@ function saRender(){
|
|
|
10230
10243
|
const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
|
|
10231
10244
|
const posLabel = solo ? '' : (pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '');
|
|
10232
10245
|
const hdKeys = solo ? '↑↓ MOVE · ESC CLOSE' : '↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE';
|
|
10233
|
-
|
|
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>
|
|
10234
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;
|
|
10235
10256
|
const body = S.querySelector('.sa-body');
|
|
10236
10257
|
if (body && restoreScroll) body.scrollTop = previousScrollTop;
|
|
10237
10258
|
// Once the person scrolls, their position wins over the short opening-focus
|
|
@@ -10285,6 +10306,10 @@ function saRender(){
|
|
|
10285
10306
|
if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
|
|
10286
10307
|
});
|
|
10287
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 });
|
|
10288
10313
|
}
|
|
10289
10314
|
// keep the open Ledger in sync with server state (SSE/refresh → render() → here).
|
|
10290
10315
|
// An item whose goal has RETURNED to 'review' (retest ×3 green, rework done,
|
|
@@ -10332,6 +10357,7 @@ async function saFetchDiffs(){
|
|
|
10332
10357
|
}
|
|
10333
10358
|
function saOpen(focusGoalId, solo = false){
|
|
10334
10359
|
SA.open = true;
|
|
10360
|
+
SA.lastMarkup = null; // a newly opened/focused dialog always gets a clean first paint
|
|
10335
10361
|
SA.solo = solo ? focusGoalId : null; // solo = a single "Needs you" goal opened as one card
|
|
10336
10362
|
SA.items = saLedgerRows().map((r) => ({ ...r, st: 0 }));
|
|
10337
10363
|
SA.detOpen = new Set();
|
|
@@ -10362,7 +10388,7 @@ function saOpen(focusGoalId, solo = false){
|
|
|
10362
10388
|
saFetchDiffs();
|
|
10363
10389
|
saEnhanceShotBoxes();
|
|
10364
10390
|
}
|
|
10365
|
-
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'); }
|
|
10366
10392
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
10367
10393
|
// ↑↓ and auto-advance to the next pending after each verdict — same screen, same
|
|
10368
10394
|
// Ledger, so approving flows without hunting for the next one.
|
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,
|
|
@@ -2953,12 +2966,10 @@ export function buildEphemeralSeedProjects(cwd) {
|
|
|
2953
2966
|
// goal/task written once (e.g. a memo the user wants to survive a restart)
|
|
2954
2967
|
// is never lost: later lines for the same id override earlier ones (last
|
|
2955
2968
|
// write wins), and a goal write with `deleted:true` removes it. Mid-flight
|
|
2956
|
-
// work
|
|
2957
|
-
//
|
|
2958
|
-
//
|
|
2959
|
-
//
|
|
2960
|
-
// the normal completion path, even if the crash happened before the PR got
|
|
2961
|
-
// 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'.
|
|
2962
2973
|
// `reviewDefinitions` (projectId -> definition, as saved by
|
|
2963
2974
|
// validateReviewDefinition) lets that settling honor each project's own
|
|
2964
2975
|
// review definition (task 45); a project with none configured falls back to
|
|
@@ -2975,7 +2986,21 @@ export function replayQueueLog(rawText, reviewDefinitions = {}) {
|
|
|
2975
2986
|
} catch { /* skip a corrupt line rather than lose the whole log */ }
|
|
2976
2987
|
}
|
|
2977
2988
|
for (const t of tasks) {
|
|
2978
|
-
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
|
+
}
|
|
2979
3004
|
}
|
|
2980
3005
|
for (const g of goals) {
|
|
2981
3006
|
if (g.status === 'planning') g.status = 'stacked';
|
|
@@ -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,7 +20,7 @@ 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';
|
|
23
|
+
import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, parseRequestedWorkspaceDir } from './lib.mjs';
|
|
24
24
|
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, 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';
|
|
@@ -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
|
|
@@ -2697,8 +2718,9 @@ async function runTask(task) {
|
|
|
2697
2718
|
|
|
2698
2719
|
// The workspace mode is chosen at goal creation and then held through every
|
|
2699
2720
|
// worker/review/PR step.
|
|
2700
|
-
const
|
|
2701
|
-
const
|
|
2721
|
+
const effectiveProject = goal ? projectForGoal(goal, project) : project;
|
|
2722
|
+
const workDir = goal ? goalWorkDir(goal, effectiveProject) : effectiveProject.dir;
|
|
2723
|
+
const wproject = { ...effectiveProject, dir: workDir };
|
|
2702
2724
|
const workspaceMode = resolveWorkspaceMode(task.workspaceMode ?? goal?.workspaceMode ?? goal?.worktree);
|
|
2703
2725
|
sendProcessAct(task, workspaceMode === 'direct-workspace'
|
|
2704
2726
|
? `Using direct workspace: ${workDir}`
|
|
@@ -2717,7 +2739,11 @@ async function runTask(task) {
|
|
|
2717
2739
|
const planMode = goal?.mode === 'plan' && goal?.planPhase !== 'executing';
|
|
2718
2740
|
const permissionMode = permissionModeFor(planMode ? 'plan' : (task.mode ?? goal?.mode));
|
|
2719
2741
|
const entryUrl = (task.passCondition && !planMode) ? entryUrlFor(goal, wproject) : null;
|
|
2720
|
-
|
|
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;
|
|
2721
2747
|
const t0 = Date.now();
|
|
2722
2748
|
|
|
2723
2749
|
// Thread replies resume the goal's Claude Code session — same context,
|
|
@@ -2882,6 +2908,10 @@ async function runTask(task) {
|
|
|
2882
2908
|
if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
|
|
2883
2909
|
approval = parseApprovalRequest(result);
|
|
2884
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
|
+
}
|
|
2885
2915
|
if (!entryUrl) break;
|
|
2886
2916
|
try {
|
|
2887
2917
|
if (!verify) {
|
|
@@ -5234,6 +5264,13 @@ const server = createServer(async (req, res) => {
|
|
|
5234
5264
|
if (!text?.trim()) return json(res, 400, { error: 'text required' });
|
|
5235
5265
|
const worker = applyReplyWorker(goal, requested);
|
|
5236
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
|
+
}
|
|
5237
5274
|
// Reply picks a parked goal back up into the work queue. 'review' MUST be
|
|
5238
5275
|
// here: a completed goal awaiting your approve/reject is 'review', and a
|
|
5239
5276
|
// reply to it means "not done — keep working" (Masa 2026-07-24: replied to
|
|
@@ -5793,6 +5830,13 @@ server.on('error', (e) => {
|
|
|
5793
5830
|
});
|
|
5794
5831
|
|
|
5795
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
|
+
}
|
|
5796
5840
|
PORT = server.address().port; // the OS-assigned one when MANAGER_PORT=0
|
|
5797
5841
|
try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
|
|
5798
5842
|
console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
|
package/package.json
CHANGED