@galda/cli 0.10.69 → 0.10.71

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/engine/lib.mjs CHANGED
@@ -89,6 +89,36 @@ export function resolveEntryUrl({ entry, projectDir, managerRoot, port, accessKe
89
89
  return pathToFileURL(abs).href;
90
90
  }
91
91
 
92
+ // Preserve the worker-declared artifact below an ephemeral Manager origin.
93
+ export function rebasePreviewUrl(declaredUrl, serverUrl) {
94
+ if (!serverUrl) return declaredUrl ?? null;
95
+ if (!declaredUrl) return serverUrl;
96
+ try {
97
+ const declared = new URL(declaredUrl);
98
+ const server = new URL(serverUrl);
99
+ const params = new URLSearchParams(declared.search);
100
+ for (const [key, value] of server.searchParams) {
101
+ if (!params.has(key)) params.set(key, value);
102
+ }
103
+ server.pathname = declared.pathname || '/';
104
+ server.search = params.toString();
105
+ server.hash = declared.hash;
106
+ return server.toString();
107
+ } catch {
108
+ return serverUrl;
109
+ }
110
+ }
111
+
112
+ export function shouldCreateGoalPR(goal, files = []) {
113
+ return goal?.wantsPR === true && files.length > 0;
114
+ }
115
+
116
+ export function normalizePullRequestUrl(value) {
117
+ const text = typeof value === 'string' ? value.trim() : '';
118
+ if (!/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/i.test(text)) return null;
119
+ return text.replace(/\/$/, '');
120
+ }
121
+
92
122
  // Extract token/cost usage from a parsed `type:'result'` stream-json event
93
123
  // (the final event of a `claude -p --output-format stream-json` run).
94
124
  // Returns null for any other event type.
@@ -157,6 +187,215 @@ export function sumUsageForToday(tasks, nowMs) {
157
187
  return sumUsage(usages);
158
188
  }
159
189
 
190
+ // Internal quality metrics -------------------------------------------------------
191
+ // These definitions intentionally describe sanitized state transitions only.
192
+ // Prompt/request bodies, worker reports, file contents, names, and emails do not
193
+ // belong in the metric payload; server.mjs writes only ids, status/category and
194
+ // timestamps to the internal event log.
195
+ export const INTERNAL_QUALITY_EVENT_DEFINITIONS = Object.freeze([
196
+ { type: 'goal_created', description: '依頼が goal として受け付けられた。prompt 本文は含めない。' },
197
+ { type: 'goal_doing_started', description: 'goal が Doing として実作業を開始した。' },
198
+ { type: 'goal_review_reached', description: 'goal が Review に到達した。' },
199
+ { type: 'goal_failed', description: 'goal が failed / blocked / interrupted / partial のいずれかに入った。' },
200
+ { type: 'goal_folded', description: 'goal が既存 Review goal に統合された。' },
201
+ { type: 'goal_fold_unfolded', description: '統合済み goal が人間操作で戻された。誤統合補正として数える。' },
202
+ { type: 'goal_reverted', description: 'goal が revert された。' },
203
+ { type: 'task_created', description: 'worker task が作られた。title/detail/prompt は含めない。' },
204
+ { type: 'task_doing_started', description: 'worker task が実行開始した。' },
205
+ { type: 'task_finished', description: 'worker task が terminal status になった。status と error category だけを含める。' },
206
+ { type: 'task_retry_requested', description: '人間または内部処理が task の再試行を要求した。' },
207
+ { type: 'task_skipped', description: 'task が skip された。理由本文は含めない。' },
208
+ ]);
209
+
210
+ const INTERNAL_TERMINAL_TASK_STATUSES = new Set(['done', 'failed', 'interrupted', 'skipped']);
211
+ const INTERNAL_FAILURE_GOAL_STATUSES = new Set(['failed', 'blocked', 'interrupted', 'partial']);
212
+ const INTERNAL_OPEN_GOAL_STATUSES = new Set(['stacked', 'planning', 'running', 'needsInput', 'blocked', 'partial', 'retesting']);
213
+
214
+ function timeMs(value) {
215
+ if (!value) return null;
216
+ const n = Date.parse(value);
217
+ return Number.isFinite(n) ? n : null;
218
+ }
219
+
220
+ function firstTime(current, value) {
221
+ const n = timeMs(value);
222
+ if (n == null) return current ?? null;
223
+ return current == null ? n : Math.min(current, n);
224
+ }
225
+
226
+ function summarizeMs(values) {
227
+ const list = values.filter((n) => Number.isFinite(n) && n >= 0).sort((a, b) => a - b);
228
+ if (!list.length) return { count: 0, avg: null, p50: null, p90: null, min: null, max: null };
229
+ const pick = (q) => list[Math.min(list.length - 1, Math.floor((list.length - 1) * q))];
230
+ return {
231
+ count: list.length,
232
+ avg: Math.round(list.reduce((a, b) => a + b, 0) / list.length),
233
+ p50: pick(0.5),
234
+ p90: pick(0.9),
235
+ min: list[0],
236
+ max: list[list.length - 1],
237
+ };
238
+ }
239
+
240
+ function rate(count, total) {
241
+ return { count, total, rate: total > 0 ? count / total : null };
242
+ }
243
+
244
+ export function classifyInternalQualityTaskError(task = {}) {
245
+ if (!['failed', 'interrupted'].includes(task.status)) return null;
246
+ const haystack = [
247
+ task.result,
248
+ task.error,
249
+ task.prError,
250
+ task.testResult?.detail,
251
+ ...(Array.isArray(task.testResult?.failingNames) ? task.testResult.failingNames : []),
252
+ ].filter(Boolean).join('\n');
253
+ if (task.testResult?.infraOnly || /server exited|server did not report ready|TimeoutError|Target closed|ECONNREFUSED|ENOSPC|EACCES|EROFS|net::|Navigation timeout|Waiting failed|timed out waiting/i.test(haystack)) {
254
+ return 'infra';
255
+ }
256
+ if (/claude|codex|worker|agent|auth|credential|logged out|bad credentials|not connected|command not found|spawn|rate limit|quota|usage limit|session unavailable|warm session|exit code|run limit|SIGTERM|SIGKILL/i.test(haystack)) {
257
+ return 'agent';
258
+ }
259
+ return null;
260
+ }
261
+
262
+ export function computeInternalQualityMetrics({ goals = [], tasks = [], events = [], nowMs = Date.now(), staleAfterMs = 24 * 60 * 60 * 1000 } = {}) {
263
+ const goalRows = new Map();
264
+ const taskRows = new Map();
265
+ const goal = (id) => {
266
+ if (id == null) return null;
267
+ if (!goalRows.has(id)) goalRows.set(id, { id, statuses: new Set(), folded: false, mergeCorrected: false });
268
+ return goalRows.get(id);
269
+ };
270
+ const task = (id) => {
271
+ if (id == null) return null;
272
+ if (!taskRows.has(id)) taskRows.set(id, { id, statuses: new Set(), retry: false });
273
+ return taskRows.get(id);
274
+ };
275
+
276
+ for (const g of goals ?? []) {
277
+ const row = goal(g?.id);
278
+ if (!row) continue;
279
+ row.projectId = g.projectId ?? row.projectId ?? null;
280
+ row.createdAt = firstTime(row.createdAt, g.createdAt);
281
+ row.doingAt = firstTime(row.doingAt, g.startedAt);
282
+ row.status = g.status ?? row.status ?? null;
283
+ if (g.status) row.statuses.add(g.status);
284
+ if (g.status === 'review') row.reviewAt = firstTime(row.reviewAt, g.reviewReachedAt ?? g.updatedAt);
285
+ if (g.status === 'folded' || g.foldedInto != null) row.folded = true;
286
+ if (g.noFold || g.status === 'reverted') row.mergeCorrected = true;
287
+ }
288
+ for (const t of tasks ?? []) {
289
+ const row = task(t?.id);
290
+ if (!row) continue;
291
+ row.goalId = t.goalId ?? row.goalId ?? null;
292
+ row.projectId = t.projectId ?? row.projectId ?? null;
293
+ row.createdAt = firstTime(row.createdAt, t.createdAt);
294
+ row.doingAt = firstTime(row.doingAt, t.startedAt);
295
+ row.finishedAt = firstTime(row.finishedAt, t.finishedAt);
296
+ row.status = t.status ?? row.status ?? null;
297
+ if (t.status) row.statuses.add(t.status);
298
+ if ((t.runAttempts ?? 0) > 1 || (t.retryCount ?? 0) > 0) row.retry = true;
299
+ row.errorCategory = row.errorCategory ?? classifyInternalQualityTaskError(t);
300
+ }
301
+
302
+ for (const e of events ?? []) {
303
+ const at = e?.at;
304
+ if (e?.goalId != null) {
305
+ const row = goal(e.goalId);
306
+ if (row) {
307
+ row.projectId = e.projectId ?? row.projectId ?? null;
308
+ if (e.type === 'goal_created') row.createdAt = firstTime(row.createdAt, at);
309
+ if (e.type === 'goal_doing_started') row.doingAt = firstTime(row.doingAt, at);
310
+ if (e.type === 'goal_review_reached') row.reviewAt = firstTime(row.reviewAt, at);
311
+ if (e.type === 'goal_failed') row.statuses.add(e.status ?? 'failed');
312
+ if (e.type === 'goal_folded') row.folded = true;
313
+ if (e.type === 'goal_fold_unfolded' || e.type === 'goal_reverted') row.mergeCorrected = true;
314
+ }
315
+ }
316
+ if (e?.taskId != null) {
317
+ const row = task(e.taskId);
318
+ if (row) {
319
+ row.goalId = e.goalId ?? row.goalId ?? null;
320
+ row.projectId = e.projectId ?? row.projectId ?? null;
321
+ if (e.type === 'task_created') row.createdAt = firstTime(row.createdAt, at);
322
+ if (e.type === 'task_doing_started') row.doingAt = firstTime(row.doingAt, at);
323
+ if (e.type === 'task_finished') {
324
+ row.finishedAt = firstTime(row.finishedAt, at);
325
+ row.status = e.status ?? row.status ?? null;
326
+ if (e.status) row.statuses.add(e.status);
327
+ row.errorCategory = e.category ?? row.errorCategory ?? null;
328
+ }
329
+ if (e.type === 'task_retry_requested') row.retry = true;
330
+ if (e.type === 'task_skipped') {
331
+ row.status = 'skipped';
332
+ row.statuses.add('skipped');
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ const goalList = [...goalRows.values()];
339
+ const taskList = [...taskRows.values()];
340
+ const startedGoals = goalList.filter((g) => g.doingAt != null);
341
+ const reviewGoals = startedGoals.filter((g) => g.reviewAt != null || g.statuses.has('review'));
342
+ const failedGoals = startedGoals.filter((g) => [...g.statuses].some((s) => INTERNAL_FAILURE_GOAL_STATUSES.has(s)));
343
+ const terminalTasks = taskList.filter((t) => INTERNAL_TERMINAL_TASK_STATUSES.has(t.status) || [...t.statuses].some((s) => INTERNAL_TERMINAL_TASK_STATUSES.has(s)));
344
+ const failedTasks = taskList.filter((t) => t.statuses.has('failed') || t.statuses.has('interrupted') || ['failed', 'interrupted'].includes(t.status));
345
+ const retryTasks = taskList.filter((t) => t.retry);
346
+ const skippedTasks = taskList.filter((t) => t.statuses.has('skipped') || t.status === 'skipped');
347
+ const infraTasks = terminalTasks.filter((t) => t.errorCategory === 'infra');
348
+ const agentTasks = terminalTasks.filter((t) => t.errorCategory === 'agent');
349
+ const foldedGoals = goalList.filter((g) => g.folded);
350
+ const mergeCorrectedGoals = foldedGoals.filter((g) => g.mergeCorrected);
351
+ const staleGoals = goalList.filter((g) => {
352
+ if (!INTERNAL_OPEN_GOAL_STATUSES.has(g.status)) return false;
353
+ const base = g.doingAt ?? g.createdAt;
354
+ return base != null && nowMs - base >= staleAfterMs;
355
+ });
356
+
357
+ return {
358
+ generatedAt: new Date(nowMs).toISOString(),
359
+ privacy: {
360
+ promptIncluded: false,
361
+ personalInfoIncluded: false,
362
+ fields: ['ids', 'projectId', 'status', 'category', 'timestamps'],
363
+ },
364
+ staleAfterMs,
365
+ eventDefinitions: INTERNAL_QUALITY_EVENT_DEFINITIONS,
366
+ counts: {
367
+ goals: goalList.length,
368
+ startedGoals: startedGoals.length,
369
+ reviewReachedGoals: reviewGoals.length,
370
+ failedGoals: failedGoals.length,
371
+ tasks: taskList.length,
372
+ terminalTasks: terminalTasks.length,
373
+ failedTasks: failedTasks.length,
374
+ retryTasks: retryTasks.length,
375
+ skippedTasks: skippedTasks.length,
376
+ infraErrorTasks: infraTasks.length,
377
+ agentErrorTasks: agentTasks.length,
378
+ foldedGoals: foldedGoals.length,
379
+ mergeCorrectedGoals: mergeCorrectedGoals.length,
380
+ staleGoals: staleGoals.length,
381
+ },
382
+ rates: {
383
+ reviewReach: rate(reviewGoals.length, startedGoals.length),
384
+ failure: rate(failedGoals.length, startedGoals.length),
385
+ retry: rate(retryTasks.length, failedTasks.length || terminalTasks.length),
386
+ infraError: rate(infraTasks.length, terminalTasks.length),
387
+ agentError: rate(agentTasks.length, terminalTasks.length),
388
+ misintegration: rate(mergeCorrectedGoals.length, foldedGoals.length),
389
+ stale: rate(staleGoals.length, goalList.length),
390
+ skip: rate(skippedTasks.length, terminalTasks.length || taskList.length),
391
+ },
392
+ durations: {
393
+ requestToDoingMs: summarizeMs(startedGoals.map((g) => g.doingAt - g.createdAt)),
394
+ doingToReviewMs: summarizeMs(reviewGoals.map((g) => g.reviewAt == null ? null : g.reviewAt - g.doingAt)),
395
+ },
396
+ };
397
+ }
398
+
160
399
  // Token-frugal execution planning -------------------------------------------------
161
400
  // A visible Project is the user's mental workspace. Internally, Manager can
162
401
  // route goals into smaller workstreams and session policies so unrelated work
@@ -268,6 +507,87 @@ export function buildContextHandoffSummary({ goal = null, tasks = [] } = {}) {
268
507
  ].filter(Boolean).join('\n').slice(0, 4000);
269
508
  }
270
509
 
510
+ export function goalSessionFor(goal, agent) {
511
+ if (!goal) return null;
512
+ const a = String(agent || goal.agent || 'claude-code');
513
+ const map = goal.sessions && typeof goal.sessions === 'object' ? goal.sessions : null;
514
+ if (map?.[a]) return String(map[a]);
515
+ if (goal.sessionId && !map && (!goal.sessionAgent || goal.sessionAgent === a)) return String(goal.sessionId);
516
+ return null;
517
+ }
518
+
519
+ export function setGoalAgentSession(goal, agent, sessionId) {
520
+ if (!goal || !sessionId) return null;
521
+ const a = String(agent || goal.agent || 'claude-code');
522
+ goal.sessions = { ...(goal.sessions && typeof goal.sessions === 'object' ? goal.sessions : {}), [a]: String(sessionId) };
523
+ if (!goal.agent || goal.agent === a) {
524
+ goal.sessionId = String(sessionId);
525
+ goal.sessionAgent = a;
526
+ }
527
+ return String(sessionId);
528
+ }
529
+
530
+ export function switchGoalAgentSession(goal, nextAgent) {
531
+ if (!goal) return null;
532
+ const previousAgent = String(goal.agent || 'claude-code');
533
+ if (goal.sessionId) setGoalAgentSession(goal, previousAgent, goal.sessionId);
534
+ goal.agent = nextAgent;
535
+ const sessionId = goalSessionFor(goal, nextAgent);
536
+ goal.sessionId = sessionId;
537
+ goal.sessionAgent = nextAgent;
538
+ return sessionId;
539
+ }
540
+
541
+ function compactList(items, { label, empty = 'not detected', max = 12 } = {}) {
542
+ const vals = [...new Set((items ?? []).map((x) => String(x ?? '').trim()).filter(Boolean))];
543
+ if (!vals.length) return `${label}: ${empty}`;
544
+ const shown = vals.slice(0, max);
545
+ const rest = vals.length - shown.length;
546
+ return `${label}: ${shown.join(', ')}${rest > 0 ? ` (+${rest} more)` : ''}`;
547
+ }
548
+
549
+ export function buildWorkerContextBlock({
550
+ agentName = 'claude-code',
551
+ model = null,
552
+ effort = null,
553
+ permissionMode = null,
554
+ cwd = null,
555
+ resumeSession = null,
556
+ sessionMode = null,
557
+ projectRules = [],
558
+ skills = [],
559
+ mcpSettings = [],
560
+ images = [],
561
+ selectedSkill = null,
562
+ } = {}) {
563
+ const displayName = agentName === 'codex' ? 'Codex' : 'Claude Code';
564
+ const rules = (projectRules ?? []).map((r) => typeof r === 'string' ? r : r?.name).filter(Boolean);
565
+ const skillNames = (skills ?? []).map((s) => {
566
+ if (typeof s === 'string') return s;
567
+ const prefix = s?.kind === 'command' ? '/' : '';
568
+ const suffix = s?.source ? ` (${s.source})` : '';
569
+ return s?.name ? `${prefix}${s.name}${suffix}` : '';
570
+ });
571
+ const session = resumeSession
572
+ ? `resume ${resumeSession}`
573
+ : sessionMode === 'cold-handoff' || sessionMode === 'compact'
574
+ ? `${sessionMode}; no hidden transcript assumed`
575
+ : 'fresh for this agent/goal';
576
+ return [
577
+ 'MANAGER-RUN CONTEXT (explicitly inherited; missing items are shown instead of silently dropped):',
578
+ `Agent: ${displayName} (${agentName})`,
579
+ `Selected model/effort: ${model || 'not specified'} / ${effort || 'not specified'}`,
580
+ `Permission mode: ${permissionMode || 'not specified'}`,
581
+ `Session: ${session}`,
582
+ `Working directory: ${cwd || 'not specified'}`,
583
+ compactList(rules, { label: 'Project rules files', empty: 'CLAUDE.md/AGENTS.md not detected in this worktree' }),
584
+ compactList(skillNames, { label: 'Available skills/commands' }),
585
+ selectedSkill ? `Selected skill: /${selectedSkill}` : 'Selected skill: none',
586
+ compactList(mcpSettings, { label: 'MCP/settings', empty: 'not detected or not introspected' }),
587
+ (images ?? []).length ? `Attached images: ${images.length} (${images.join(', ')})` : 'Attached images: none',
588
+ ].join('\n');
589
+ }
590
+
271
591
  // task 6 (2026-07-16): "saved X%" used to be a proxy for cache-hit ratio —
272
592
  // this goal's own cache-read tokens discounted 0.1x, compared against this
273
593
  // same goal's raw total. That's real math, but it has nothing to do with
@@ -879,6 +1199,42 @@ export function workerResultText({ result = '', err = '', code = 0, timedOut = f
879
1199
  return tail && isForcedStop(code, timedOut) ? `${reason}\n最後の作業 / last step: ${tail}` : reason;
880
1200
  }
881
1201
 
1202
+ export function classifyWorkerFailure({ agent = 'claude-code', result = '', err = '', code = 0, timedOut = false, phase = '' } = {}) {
1203
+ const text = [result, err, phase].map((v) => String(v ?? '')).filter(Boolean).join('\n');
1204
+ const low = text.toLowerCase();
1205
+ const infra = (reason, actionIds = ['retry', 'view-log']) => ({
1206
+ owner: 'galda-infra',
1207
+ reason,
1208
+ actionIds,
1209
+ });
1210
+ if (timedOut || /run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) {
1211
+ return infra('timeout', ['retry', 'run-with-codex', 'view-log']);
1212
+ }
1213
+ if (/spawn|enoent|eacces|command not found|executable|worker tool exited early|作業ツールが途中で終了/.test(low)) {
1214
+ return infra('worker-start', ['retry', 'run-with-codex', 'view-log']);
1215
+ }
1216
+ if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) {
1217
+ return infra('auth', ['connect', 'check-connection', 'run-with-codex', 'view-log']);
1218
+ }
1219
+ if (/relay|websocket|socket|econnreset|econnrefused|billing-api unreachable|cf access|cloudflare access/.test(low)) {
1220
+ return infra('relay', ['retry', 'connect', 'view-log']);
1221
+ }
1222
+ if (/chrome|chromium|puppeteer|target closed|browser|navigation timeout|waiting failed|screenshot|headless/.test(low)) {
1223
+ return infra('chrome', ['retry', 'view-log']);
1224
+ }
1225
+ if (/test runner|node --test|npm test|project tests|server exited \(code \d+\) before it was ready|did not report ready in time|test timed out after|tap|not ok \d+/.test(low)) {
1226
+ return infra('test-runner', ['retry-tests', 'view-log']);
1227
+ }
1228
+ if (/pr step failed|openpr|pull request|github|gh:|gh auth|git push|git fetch|git .*failed|pr作成|pr failed/.test(low)) {
1229
+ return infra('pr', ['retry-pr', 'view-log']);
1230
+ }
1231
+ return {
1232
+ owner: 'agent',
1233
+ reason: agent === 'codex' ? 'codex' : 'claude-code',
1234
+ actionIds: ['retry', agent === 'claude-code' ? 'run-with-codex' : 'run-with-claude', 'view-log'],
1235
+ };
1236
+ }
1237
+
882
1238
  // Was the worker forcibly STOPPED (timeout or SIGTERM) rather than failing on
883
1239
  // its own? Such a task is 'interrupted' (retryable, not a code failure) — never
884
1240
  // a bare 'failed', which reads as "the worker's code was wrong". Mirrors the
@@ -1000,6 +1356,7 @@ export function buildGoalProofMd({ goalText, items, lang = 'ja' }) {
1000
1356
  // doesn't have to re-read the raw worker log to know what to look at.
1001
1357
  export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, proofRel, proofItemsCount, reviewDescription, reviewSummary }) {
1002
1358
  const L = (en, ja) => (lang === 'en' ? en : ja);
1359
+ const workspaceMode = workspaceModeLabel(goal?.workspaceMode ?? goal?.worktree, lang);
1003
1360
  return [
1004
1361
  ...((reviewSummary?.check || reviewSummary?.changed) ? [
1005
1362
  L('## Check first', '## 確認ポイント'),
@@ -1018,6 +1375,9 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
1018
1375
  '',
1019
1376
  goal.text.slice(0, 1500),
1020
1377
  '',
1378
+ L(`Workspace mode: ${workspaceMode}`, `workspace mode: ${workspaceMode}`),
1379
+ ...(goal.workspaceWarning ? ['', L(`Workspace warning: ${goal.workspaceWarning}`, `workspace warning: ${goal.workspaceWarning}`)] : []),
1380
+ '',
1021
1381
  ...buildReviewRuleSection(reviewDescription),
1022
1382
  L(`Implemented by Manager for AI: the goal was decomposed into ${goalTasks.length} task(s), each executed by a Claude Code worker (flat-rate, no extra API), tests written and run per task.${proofItemsCount ? ' Tasks with a pass condition were verified independently in a headless browser.' : ''}`,
1023
1383
  `Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItemsCount ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
@@ -1147,6 +1507,54 @@ export function classifyTestGate({ current = [], baseline = [] } = {}) {
1147
1507
  return { newFailures, preExisting, blocked: newFailures.length > 0 };
1148
1508
  }
1149
1509
 
1510
+ const PR_SAFETY_SCRATCH_RE = /(^|\/)\.tmp|\.log$|(^|\/)\.manager-wt(\/|$)|(^|\/)node_modules(\/|$)|(^|\/)secret\.key$/;
1511
+
1512
+ export function classifyPrSafety({ baseline = null, files = [], currentFiles = [], currentBranch = null } = {}) {
1513
+ if (!baseline?.sharedCheckout) return null;
1514
+ const dirty = (baseline.dirtyFiles ?? []).filter((f) => f && !PR_SAFETY_SCRATCH_RE.test(f));
1515
+ if (dirty.length) {
1516
+ return {
1517
+ code: 'dirty-baseline',
1518
+ message: [
1519
+ 'PR creation skipped: this goal started in a dirty shared workspace, so Manager cannot prove the PR would contain only this task.',
1520
+ `Existing dirty files at goal start: ${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ` +${dirty.length - 8} more` : ''}.`,
1521
+ 'Repair: commit/stash/revert those unrelated changes, then rerun this reply with worktree on or from a clean checkout.',
1522
+ ].join(' '),
1523
+ };
1524
+ }
1525
+ if ((Number(baseline.aheadOfDefault) || 0) > 0) {
1526
+ return {
1527
+ code: 'branch-history',
1528
+ message: [
1529
+ 'PR creation skipped: this goal started from a shared checkout with local commits ahead of the default branch.',
1530
+ 'Repair: switch to a clean default-branch checkout or enable worktree isolation, then rerun the task/reply so only this goal is in the PR.',
1531
+ ].join(' '),
1532
+ };
1533
+ }
1534
+ if (baseline.branch && currentBranch && baseline.branch !== currentBranch) {
1535
+ return {
1536
+ code: 'branch-changed',
1537
+ message: [
1538
+ `PR creation skipped: the shared checkout branch changed from ${baseline.branch} to ${currentBranch} during this goal.`,
1539
+ 'Repair: return to the baseline branch or rerun from a clean isolated worktree.',
1540
+ ].join(' '),
1541
+ };
1542
+ }
1543
+ const allowed = new Set(files ?? []);
1544
+ const extra = (currentFiles ?? []).filter((f) => f && !allowed.has(f) && !PR_SAFETY_SCRATCH_RE.test(f));
1545
+ if (extra.length) {
1546
+ return {
1547
+ code: 'extra-dirty-files',
1548
+ message: [
1549
+ 'PR creation skipped: the shared checkout contains files outside this goal/task change list.',
1550
+ `Extra files: ${extra.slice(0, 8).join(', ')}${extra.length > 8 ? ` +${extra.length - 8} more` : ''}.`,
1551
+ 'Repair: move those unrelated changes away, then rerun PR creation from a clean checkout.',
1552
+ ].join(' '),
1553
+ };
1554
+ }
1555
+ return null;
1556
+ }
1557
+
1150
1558
  // A manager test-run that was KILLED before it could finish is inconclusive, not
1151
1559
  // a red. When the machine is saturated (prod + several worker goals all standing
1152
1560
  // up real servers + headless Chrome), `npm test` gets killed at its execFile
@@ -1232,6 +1640,40 @@ export function classifyVerifyGate({
1232
1640
  return { blocked, kind, proofAdvisory };
1233
1641
  }
1234
1642
 
1643
+ export function buildVerificationInfraError({ source = 'verification', detail = '', retryOnly = true } = {}) {
1644
+ const text = String(detail ?? '').replace(/\s+/g, ' ').trim();
1645
+ if (!text) return null;
1646
+ return {
1647
+ source,
1648
+ title: '検証基盤エラー',
1649
+ detail: text.slice(0, 600),
1650
+ retryOnly: !!retryOnly,
1651
+ };
1652
+ }
1653
+
1654
+ export function verificationInfraErrorFrom({ testResult = null, proofNote = null } = {}) {
1655
+ if (testResult?.inconclusive) {
1656
+ return buildVerificationInfraError({
1657
+ source: 'tests',
1658
+ detail: 'Project tests could not complete because the verification runner was interrupted or saturated. The implementation result is still ready for human review; press 再検証 to rerun verification only.',
1659
+ });
1660
+ }
1661
+ if (testResult?.infraOnly) {
1662
+ const n = testResult.infraFlakes || testResult.failed || '';
1663
+ return buildVerificationInfraError({
1664
+ source: 'tests',
1665
+ detail: `Project tests had ${n} infrastructure-only failure(s): a server could not boot, Chrome closed, or a wait timed out under machine load. This is not treated as an implementation failure; press 再検証 to rerun verification only.`,
1666
+ });
1667
+ }
1668
+ if (proofNote) {
1669
+ return buildVerificationInfraError({
1670
+ source: 'proof',
1671
+ detail: `${proofNote}。実装結果はReviewで確認できます。再検証ではコードを再実装せず、検証だけを再実行します。`,
1672
+ });
1673
+ }
1674
+ return null;
1675
+ }
1676
+
1235
1677
  // Serializes async work so at most one item runs at a time. The goal-verify
1236
1678
  // pipeline uses this to stop two goals' `npm test` runs (each spins up real
1237
1679
  // headless Chrome + real servers) from ever overlapping — running two full
@@ -1533,6 +1975,7 @@ export function buildReviewDigest({ goals, tasks } = {}) {
1533
1975
  checkLine: plan ? `PLAN — approve to execute: ${checkLine}`.slice(0, 90) : checkLine,
1534
1976
  pr: g.pr ?? null,
1535
1977
  testResult: g.testResult ?? null,
1978
+ verificationInfraError: g.verificationInfraError ?? null,
1536
1979
  proof: goalTasks.find((t) => t.proof)?.proof ?? null,
1537
1980
  plan,
1538
1981
  planText: plan ? (g.planText ?? '') : null,
@@ -1545,6 +1988,7 @@ export function buildReviewDigest({ goals, tasks } = {}) {
1545
1988
  }
1546
1989
  if (existing.proof == null) existing.proof = row.proof;
1547
1990
  if (existing.testResult == null) existing.testResult = row.testResult;
1991
+ if (existing.verificationInfraError == null) existing.verificationInfraError = row.verificationInfraError;
1548
1992
  continue;
1549
1993
  }
1550
1994
  if (row.pr) byPr.set(row.pr, row);
@@ -2098,6 +2542,25 @@ export function hasGitChanges(porcelainStatus) {
2098
2542
  return Boolean(String(porcelainStatus ?? '').trim());
2099
2543
  }
2100
2544
 
2545
+ export function resolveWorkspaceMode(input) {
2546
+ if (input === 'direct-workspace' || input === 'direct') return 'direct-workspace';
2547
+ if (input === 'isolated-worktree' || input === 'worktree' || input === true) return 'isolated-worktree';
2548
+ if (input === false) return 'direct-workspace';
2549
+ return 'isolated-worktree';
2550
+ }
2551
+
2552
+ export function workspaceModeLabel(mode, lang = 'en') {
2553
+ const m = resolveWorkspaceMode(mode);
2554
+ if (m === 'direct-workspace') {
2555
+ return lang === 'ja'
2556
+ ? 'direct workspace(現在の作業状態を引き継ぐ)'
2557
+ : 'direct workspace (inherits the current working state)';
2558
+ }
2559
+ return lang === 'ja'
2560
+ ? 'isolated worktree(cleanな分離環境)'
2561
+ : 'isolated worktree (clean separate checkout)';
2562
+ }
2563
+
2101
2564
  // ---- project rules files (ONE-CONVERSATION §1.5a) --------------------------
2102
2565
  // The files that hold a project's working rules — the instructions the connected
2103
2566
  // AI actually reads (Claude Code reads CLAUDE.md from cwd; Codex reads AGENTS.md).
@@ -2139,6 +2602,67 @@ export function goalsConflict(filesA, filesB) {
2139
2602
  return b.some((f) => setA.has(f));
2140
2603
  }
2141
2604
 
2605
+ const REVIEW_FOLD_STOPWORDS = new Set([
2606
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'onto', 'task', 'todo', 'goal',
2607
+ 'fix', 'add', 'update', 'change', 'make', 'view', 'list', 'section', 'button', 'status',
2608
+ 'する', 'した', 'して', 'こと', 'ため', 'よう', 'これ', 'それ', 'ところ', 'タスク', '表示', '追加', '修正',
2609
+ 'セクション', 'カード',
2610
+ ]);
2611
+ function foldTextTokens(text) {
2612
+ const s = String(text ?? '').toLowerCase();
2613
+ const ascii = s.match(/[a-z0-9][a-z0-9+#._-]{1,}/g) ?? [];
2614
+ const cjkRuns = s.match(/[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}ー]{2,}/gu) ?? [];
2615
+ const cjk = [];
2616
+ for (const run of cjkRuns) {
2617
+ if (run.length <= 4) cjk.push(run);
2618
+ else for (let i = 0; i < run.length - 1; i++) cjk.push(run.slice(i, i + 2));
2619
+ }
2620
+ return [...new Set([...ascii, ...cjk]
2621
+ .map((t) => t.replace(/^#+/, '').trim())
2622
+ .filter((t) => t.length >= 2 && !REVIEW_FOLD_STOPWORDS.has(t)))];
2623
+ }
2624
+ export function foldObjectivesRelated(a, b) {
2625
+ const ta = foldTextTokens([a?.text, a?.title, a?.summary].filter(Boolean).join(' '));
2626
+ const tb = foldTextTokens([b?.text, b?.title, b?.summary].filter(Boolean).join(' '));
2627
+ if (!ta.length || !tb.length) return false;
2628
+ const sb = new Set(tb);
2629
+ const overlap = ta.filter((t) => sb.has(t));
2630
+ if (overlap.length < 2) return false;
2631
+ if (!overlap.some((t) => /[a-z0-9]/i.test(t))) return false;
2632
+ const smaller = Math.min(ta.length, tb.length);
2633
+ const ratio = overlap.length / Math.max(1, smaller);
2634
+ return ratio >= 0.45 || overlap.length >= 4;
2635
+ }
2636
+ export function parseGitUnifiedDiffLocations(diffText) {
2637
+ const out = [];
2638
+ let file = null;
2639
+ for (const raw of String(diffText ?? '').split('\n')) {
2640
+ const plus = raw.match(/^\+\+\+ b\/(.+)$/);
2641
+ if (plus) { file = plus[1]; continue; }
2642
+ if (raw.startsWith('+++ /dev/null')) { file = null; continue; }
2643
+ const h = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
2644
+ if (!h || !file) continue;
2645
+ const oldStart = Number(h[1]), oldLen = h[2] == null ? 1 : Number(h[2]);
2646
+ const newStart = Number(h[3]), newLen = h[4] == null ? 1 : Number(h[4]);
2647
+ out.push({
2648
+ file,
2649
+ oldStart, oldEnd: oldStart + Math.max(1, oldLen) - 1,
2650
+ newStart, newEnd: newStart + Math.max(1, newLen) - 1,
2651
+ });
2652
+ }
2653
+ return out;
2654
+ }
2655
+ function rangesOverlap(aStart, aEnd, bStart, bEnd, margin = 2) {
2656
+ return Math.max(aStart, bStart) <= Math.min(aEnd, bEnd) + margin;
2657
+ }
2658
+ export function foldLocationsOverlap(a, b) {
2659
+ const la = a?.changeLocations ?? [], lb = b?.changeLocations ?? [];
2660
+ if (!la.length || !lb.length) return false;
2661
+ return la.some((x) => lb.some((y) => x.file === y.file
2662
+ && (rangesOverlap(x.newStart, x.newEnd, y.newStart, y.newEnd)
2663
+ || rangesOverlap(x.oldStart, x.oldEnd, y.oldStart, y.oldEnd))));
2664
+ }
2665
+
2142
2666
  // Which of `otherGoals` (each `{ id, changedFiles }`) touch at least one path
2143
2667
  // this goal (`{ id, changedFiles }`) also touched? Returns their ids, in the
2144
2668
  // order they appear in `otherGoals` — server.mjs stamps these onto
@@ -2187,7 +2711,15 @@ export function pickFoldTarget(goal, candidates) {
2187
2711
  const overlapping = (candidates ?? []).filter(
2188
2712
  (g) => g && g.id !== goal.id && goalsConflict(files, g.changedFiles),
2189
2713
  );
2190
- return overlapping.length === 1 ? overlapping[0].id : null;
2714
+ const eligible = overlapping.filter((g) => foldObjectivesRelated(goal, g) || foldLocationsOverlap(goal, g));
2715
+ return eligible.length === 1 ? eligible[0].id : null;
2716
+ }
2717
+
2718
+ // Two requests remain two human-review decisions by default, even when they
2719
+ // touch the same files. Legacy automatic folding is available only when a
2720
+ // project explicitly opts into it.
2721
+ export function autoFoldReviewsEnabled(project) {
2722
+ return project?.autoFoldReviews === true;
2191
2723
  }
2192
2724
 
2193
2725
  // Which of `commits` (newest-first, as returned by parseGitLog) are new
@@ -2526,11 +3058,21 @@ export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }
2526
3058
  title: goalTaskTitle(goal.text), detail: goal.text, passCondition,
2527
3059
  model: goal.model, effort: goal.effort, agent: goal.agent,
2528
3060
  mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
3061
+ workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
3062
+ worktree: goal.worktree === false ? false : undefined,
2529
3063
  status: 'queued', createdAt, priority: '中',
2530
3064
  result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
2531
3065
  };
2532
3066
  }
2533
3067
 
3068
+ // Stability roadmap 03/08: the intake planner is no longer on the default
3069
+ // implementation route. It is reserved for the user explicitly selecting Plan
3070
+ // mode, where the product is allowed to ask the planning layer for structure or
3071
+ // clarification before the worker still receives the verbatim request.
3072
+ export function shouldUsePlannerForGoal(goal) {
3073
+ return goal?.mode === 'plan';
3074
+ }
3075
+
2534
3076
  // ---- worker prompt ---------------------------------------------------------
2535
3077
  // The instruction the worker (Claude Code / Codex) actually reads. Pure:
2536
3078
  // (task, goal, lastFailure, agentName) -> string, so it is unit-testable.
@@ -2544,13 +3086,14 @@ export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }
2544
3086
  // Together they turned "design 3 patterns as an artifact" into one minimal
2545
3087
  // tweak. North star: we never re-judge the request — the user's verbatim words
2546
3088
  // are the source of truth and the worker delivers exactly what they ask for.
2547
- export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code') {
3089
+ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code', runContext = null) {
2548
3090
  // Optional skill the user attached to this goal. Empirically (scratch worker,
2549
3091
  // haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
2550
3092
  // instruction as the FIRST line; a bare "/name" first line did not consistently
2551
3093
  // trigger the skill in `claude -p`. See commit body for the run evidence.
2552
3094
  const skill = task?.skill ?? goal?.skill;
2553
3095
  const displayName = agentName === 'codex' ? 'Codex' : 'Claude Code';
3096
+ const workspaceMode = workspaceModeLabel(task?.workspaceMode ?? goal?.workspaceMode ?? goal?.worktree, 'en');
2554
3097
  // Report back to the human in the SAME language they wrote the goal in (Masa
2555
3098
  // 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
2556
3099
  const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
@@ -2563,9 +3106,11 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
2563
3106
  // TASK/DETAIL below is a paraphrase from the planner — when they differ,
2564
3107
  // follow the user's words.
2565
3108
  goal?.text ? `USER'S REQUEST (verbatim — the source of truth; deliver exactly this):\n${goal.text}` : '',
3109
+ runContext ? (typeof runContext === 'string' ? runContext : buildWorkerContextBlock(runContext)) : '',
2566
3110
  '',
2567
3111
  `TASK ${task.num}: ${task.title}`,
2568
3112
  task.detail ? `DETAIL: ${task.detail}` : '',
3113
+ `WORKSPACE MODE: ${workspaceMode}.`,
2569
3114
  goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
2570
3115
  task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
2571
3116
  lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
@@ -2606,6 +3151,7 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
2606
3151
  // (research, summaries, answers) simply skip this — the worker decides,
2607
3152
  // never us.
2608
3153
  `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>", "title": "<この作業のチケット名。対象が分かる程度に説明的で動作を含む短い名前。例「看板モードの追加」「ヘッダのズレの修正」。依頼文をそのまま写さない>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
3154
+ `- Markdown・設計文書・調査メモなど文章そのものが成果物なら、${RUN_DECL_FILE} に別のHTMLや過去のURLを書かない。最終回答に成果物の本文も貼り、保存先のMarkdownリンクを添えること。「ファイルに書きました」だけで終わらない。`,
2609
3155
  `- 「ローカルで見れない」というフィードバックには、ターミナル操作や npx の再実行を案内して終わらないこと。${RUN_DECL_FILE} を実際に開ける内容へ更新し、可能なら自分でURLへ接続確認してから完了すること。`,
2610
3156
  // Links are rendered as links. The product cannot tell a real one from a
2611
3157
  // decorative one, so the only place this can be held is here.
@@ -3027,6 +3573,33 @@ export function latestGoalOutcomeTask(tasks = []) {
3027
3573
  - Date.parse(a.finishedAt || a.startedAt || a.createdAt || 0))[0] ?? null;
3028
3574
  }
3029
3575
 
3576
+ export function buildDirtyWorkspacePrBlock({ dirtyFiles = [], branch = null, base = null, ahead = 0 } = {}) {
3577
+ const files = [...new Set(dirtyFiles)].filter(Boolean);
3578
+ const branchRisk = branch && base && branch !== base;
3579
+ const aheadCount = Number(ahead) || 0;
3580
+ if (!files.length && !branchRisk && aheadCount === 0) return null;
3581
+ const pieces = [];
3582
+ if (files.length) pieces.push(`workspace was already dirty at goal start (${files.slice(0, 6).join(', ')}${files.length > 6 ? ` +${files.length - 6} more` : ''})`);
3583
+ if (branchRisk) pieces.push(`current branch is ${branch}, not ${base}`);
3584
+ if (aheadCount > 0) pieces.push(`current checkout has ${aheadCount} commit(s) ahead of ${base ?? 'the default branch'}`);
3585
+ return {
3586
+ message: `Not opening a PR because this run used the shared checkout and ${pieces.join('; ')}. Clean/stash unrelated changes or rerun with isolated worktree, then retry PR.`,
3587
+ repair: {
3588
+ kind: 'dirty-workspace',
3589
+ title: 'PRを止めました / PR blocked',
3590
+ steps: [
3591
+ '既存のdirty差分をcommit/stash/破棄して、このtask以外の変更を片付ける',
3592
+ 'WorktreeをONにして同じreplyを再実行する',
3593
+ '状態がきれいになったら「PR再試行」を押す',
3594
+ ],
3595
+ dirtyFiles: files.slice(0, 20),
3596
+ branch,
3597
+ base,
3598
+ ahead: aheadCount,
3599
+ },
3600
+ };
3601
+ }
3602
+
3030
3603
  // Plain-text snapshot of Review/Attention/Done fed to the /api/ask LLM pass —
3031
3604
  // same three buckets app/index.html's renderTasks() renders (goal.status
3032
3605
  // 'review'/'blocked' + task.status failed/interrupted + task.status