@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 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}」の作業中に、` : ''}実行が中断されました: ${compact.slice(0, 140) || '詳細ログは取得できませんでした'}。`
6242
- : `Needed: ${need}. The run was interrupted${taskName ? ` while working on "${taskName}"` : ''}: ${compact.slice(0, 140) || 'no detailed log was captured'}.`;
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
- ? '具体的な次の一手を指定して Resume するか、不要なら Archive してください。'
6245
- : 'Resume with a concrete next step, or archive it if the work is no longer needed.';
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
- const rev = buildReviewDigest({ goals: pgoals, tasks: ptasks }).length;
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 = inferErrorClass(t.result, t.agent || goal?.agent);
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 || 'The worker reached its run limit before finishing.').replace(/\s+/g, ' ').slice(0, 240);
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
- S.innerHTML = `<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>
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 can't resume across a restart, so it's marked retryable rather than
2957
- // silently dropped: a 'running' task becomes 'interrupted', a 'planning'
2958
- // goal goes back to 'stacked', and a 'running' goal with no queued task left
2959
- // settles via nextGoalStatus() a wantsPR goal lands in 'review' just like
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 === 'running') t.status = 'interrupted';
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 workDir = goal ? goalWorkDir(goal, project) : project.dir;
2701
- const wproject = { ...project, dir: workDir };
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
- const maxAttempts = entryUrl ? 3 : 1;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.90",
3
+ "version": "0.10.91",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {