@galda/cli 0.10.0

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.
Files changed (34) hide show
  1. package/CLAUDE.md +44 -0
  2. package/README.md +83 -0
  3. package/app/fonts.css +8 -0
  4. package/app/index.html +7638 -0
  5. package/app/theme.css +126 -0
  6. package/app/wp/w1.jpg +0 -0
  7. package/app/wp/w2.jpg +0 -0
  8. package/bin/manager-for-ai.mjs +76 -0
  9. package/engine/lib.mjs +2378 -0
  10. package/engine/manager.mjs +115 -0
  11. package/engine/mcp.mjs +123 -0
  12. package/engine/pr.mjs +144 -0
  13. package/engine/relay-client.mjs +82 -0
  14. package/engine/server.mjs +3315 -0
  15. package/engine/verify.mjs +158 -0
  16. package/examples/task-001/runs/2026-07-03T06-23-57-441Z/attempt-1-proof.png +0 -0
  17. package/examples/task-001/runs/2026-07-03T06-23-57-441Z/report.json +20 -0
  18. package/examples/task-001/runs/2026-07-03T06-29-54-517Z/attempt-1-proof.png +0 -0
  19. package/examples/task-001/runs/2026-07-03T06-29-54-517Z/report.json +20 -0
  20. package/examples/task-001/runs/2026-07-03T06-40-23-231Z/attempt-1-proof.png +0 -0
  21. package/examples/task-001/runs/2026-07-03T06-40-23-231Z/report.json +20 -0
  22. package/examples/task-001/runs/2026-07-03T07-34-58-109Z/attempt-1-proof.png +0 -0
  23. package/examples/task-001/runs/2026-07-03T07-34-58-109Z/report.json +21 -0
  24. package/examples/task-001/runs/2026-07-03T07-53-44-639Z/attempt-1-proof.png +0 -0
  25. package/examples/task-001/runs/2026-07-03T07-53-44-639Z/report.json +21 -0
  26. package/examples/task-001/runs/2026-07-03T08-02-12-117Z/attempt-1-proof.png +0 -0
  27. package/examples/task-001/runs/2026-07-03T08-02-12-117Z/report.json +21 -0
  28. package/examples/task-001/task.json +11 -0
  29. package/examples/task-001/verify.mjs +16 -0
  30. package/examples/toast-app/app.js +23 -0
  31. package/examples/toast-app/index.html +28 -0
  32. package/examples/toast-app/test/guard.test.mjs +83 -0
  33. package/examples/toast-app/test/style.test.mjs +19 -0
  34. package/package.json +52 -0
@@ -0,0 +1,3315 @@
1
+ #!/usr/bin/env node
2
+ // Manager for AI — chat → Claude Code server (dogfood v0.2)
3
+ //
4
+ // node engine/server.mjs → http://localhost:4400
5
+ //
6
+ // v0.2: a chat message is a GOAL. The Manager first decomposes it into
7
+ // numbered tasks (planner = claude -p), then runs each task with a Claude
8
+ // Code worker whose live activity (tool calls) streams to the UI. Queues are
9
+ // per-project (galda1 ∥ galda2, serial within a project). Workers can run
10
+ // node/npm for tests but have no git — they can never commit, push or deploy.
11
+ // If the goal asks for a pull request, the Manager opens one from a temp
12
+ // worktree once all tasks are done (the user's working tree is never touched).
13
+
14
+ import { createServer } from 'node:http';
15
+ import { spawn, execFile, spawnSync } from 'node:child_process';
16
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
17
+ import { randomBytes } from 'node:crypto';
18
+ import { resolve, dirname, join, basename } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { pathToFileURL } from 'node:url';
22
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken } from './lib.mjs';
23
+ import { openPR } from './pr.mjs';
24
+ import { runVerification, exerciseUi } from './verify.mjs';
25
+
26
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
27
+ // repo checkout keeps state in engine/ as before; an installed package
28
+ // keeps it in ~/.manager-for-ai (never inside the npx cache)
29
+ const DATA_DIR = process.env.MANAGER_HOME
30
+ ?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
31
+ mkdirSync(DATA_DIR, { recursive: true });
32
+ const PORT = Number(process.env.MANAGER_PORT ?? 4400);
33
+
34
+ // Access key: required for every request when the server is exposed beyond
35
+ // localhost (e.g. through a tunnel). Auto-generated once, kept out of git.
36
+ const keyFile = join(DATA_DIR, 'secret.key');
37
+ if (!existsSync(keyFile)) writeFileSync(keyFile, randomBytes(18).toString('base64url'));
38
+ const ACCESS_KEY = readFileSync(keyFile, 'utf8').trim();
39
+ // Per-worker run cap. Env-configurable so it can be tuned operationally and the
40
+ // timeout→'interrupted' path is testable (a test sets a short value + a slow
41
+ // fake worker instead of waiting 10 real minutes).
42
+ const WORKER_TIMEOUT_MS = Number(process.env.MANAGER_WORKER_TIMEOUT_MS) || 15 * 60 * 1000;
43
+ const MODELS = ['sonnet', 'opus', 'haiku'];
44
+ const CODEX_MODELS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'];
45
+ const WORKER_AGENTS = ['claude-code', 'codex'];
46
+ const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
47
+ const CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
48
+ function workerAgent(agent) { return WORKER_AGENTS.includes(agent) ? agent : 'claude-code'; }
49
+ function workerModel(agent, model) {
50
+ return workerAgent(agent) === 'codex'
51
+ ? (CODEX_MODELS.includes(model) ? model : CODEX_MODELS[0])
52
+ : (MODELS.includes(model) ? model : 'sonnet');
53
+ }
54
+ function workerEffort(agent, effort) {
55
+ const list = workerAgent(agent) === 'codex' ? CODEX_EFFORTS : CLAUDE_EFFORTS;
56
+ return list.includes(effort) ? effort : 'medium';
57
+ }
58
+
59
+ // ---- goal 427: token/quota-frugal guardrails ------------------------------
60
+ // Per-goal run budget (checkRunBudget in lib.mjs does the actual decision).
61
+ // Defaults are deliberately generous — a legitimate multi-task goal with a
62
+ // few verify retries and a rework round or two should never trip this; it
63
+ // exists to stop a genuinely runaway goal (stuck retry loop, a goal that
64
+ // somehow never converges) from quietly burning the weekly quota while the
65
+ // user is AFK ("wake up to evidence, not a burned quota" — PRD §4/§6).
66
+ // All three are overridable via env for anyone who wants a tighter leash.
67
+ function goalRunBudgetLimits() {
68
+ return {
69
+ maxRuntimeMs: Number(process.env.MANAGER_GOAL_MAX_RUNTIME_MS) || 2 * 60 * 60 * 1000, // 2h of actual worker wall-time
70
+ maxTokens: Number(process.env.MANAGER_GOAL_MAX_TOKENS) || 3_000_000, // combined in+out+cache tokens
71
+ maxAttempts: Number(process.env.MANAGER_GOAL_MAX_ATTEMPTS) || 20, // cumulative `claude` spawns for this goal
72
+ };
73
+ }
74
+ // Rate-limit pause/resume backoff (nextResumeDelay in lib.mjs). Overridable
75
+ // so tests (and an impatient human) don't have to wait a real 5 minutes.
76
+ function rateLimitDelayOpts() {
77
+ return {
78
+ baseMs: Number(process.env.MANAGER_RATE_LIMIT_BASE_MS) || 5 * 60 * 1000,
79
+ maxMs: Number(process.env.MANAGER_RATE_LIMIT_MAX_MS) || 60 * 60 * 1000,
80
+ };
81
+ }
82
+ // How often the resume sweep checks for a rate-limited goal whose cooldown
83
+ // has elapsed (see rateLimitResumeSweep below). Restart-durable: the sweep
84
+ // re-derives "is it time yet" from goal.blocked.resumeAt (persisted), so a
85
+ // server restart during the pause just means the sweep picks it up on the
86
+ // next tick instead of losing an in-memory timer.
87
+ const RATE_LIMIT_SWEEP_MS = Number(process.env.MANAGER_RATE_LIMIT_SWEEP_MS) || 15000;
88
+ const MAX_AUTO_TEST_REWORKS = Number(process.env.MANAGER_AUTO_TEST_REWORKS ?? 2);
89
+ const MAX_AUTO_BUDGET_REWORKS = Number(process.env.MANAGER_AUTO_BUDGET_REWORKS ?? 1);
90
+ const CACHE_READ_TOKEN_WEIGHT = Number(process.env.MANAGER_CACHE_READ_TOKEN_WEIGHT ?? 0.1);
91
+
92
+ // Slow mode (goal 427 §3): an opt-in pacing so an AFK run doesn't burn
93
+ // through quota as fast as the hardware allows. Off by default (unchanged
94
+ // behaviour). On: caps this project's own parallelism to 1 concurrent task
95
+ // and enforces a minimum gap between successive task STARTS (not between
96
+ // goals — a goal's own multi-task sequence already runs serially).
97
+ function slowModeSettings() {
98
+ const enabled = /^(1|true|on)$/i.test(String(process.env.MANAGER_SLOW_MODE ?? ''));
99
+ return {
100
+ enabled,
101
+ delayMs: Number(process.env.MANAGER_SLOW_MODE_DELAY_MS) || 60 * 1000,
102
+ maxParallel: Number(process.env.MANAGER_SLOW_MODE_MAX_PARALLEL) || 1,
103
+ };
104
+ }
105
+
106
+ // Cloudflare Access (Google login) — stage 1 of "common URL → Google login".
107
+ // When this hostname sits behind CF Access, CF injects a signed RS256 JWT
108
+ // (`Cf-Access-Jwt-Assertion`) on every request after the visitor signs in
109
+ // with Google. We verify that JWT (signature + exp + aud, see
110
+ // verifyCfAccessJwt in lib.mjs) and treat a valid one as authed — on top of,
111
+ // not instead of, the existing ?key= gate, which keeps working for
112
+ // dev/localhost and as a backward-compatible fallback. Unset either env var
113
+ // and CF Access checking is fully disabled (only ?key= works) — it never
114
+ // "fails open".
115
+ const CF_ACCESS_TEAM_DOMAIN = process.env.CF_ACCESS_TEAM_DOMAIN ?? '';
116
+ const CF_ACCESS_AUD = process.env.CF_ACCESS_AUD ?? '';
117
+ const CF_ACCESS_ENABLED = Boolean(CF_ACCESS_TEAM_DOMAIN && CF_ACCESS_AUD);
118
+ const CF_ACCESS_JWKS_TTL_MS = 10 * 60 * 1000;
119
+
120
+ // Stage 2 ("common URL → branch by user"): Masa's own Google address, so
121
+ // that logging in via CF Access still resolves to the OWNER identity and he
122
+ // keeps seeing every pre-existing goal (see resolveIdentity/goalVisibleTo in
123
+ // lib.mjs). Unset → only key-auth is the owner; every other authed email is
124
+ // just another allow-listed user with their own, separate goal set.
125
+ const MANAGER_OWNER_EMAIL = process.env.MANAGER_OWNER_EMAIL ?? '';
126
+
127
+ // Public self-serve billing (docs/BILLING-LAUNCH-PLAN.md): the independent
128
+ // Cloudflare Worker that owns Stripe + isPaying lookup (workers/billing-api).
129
+ // Unset → billing-api is never called and every non-owner identity is judged
130
+ // on the free tier alone (fetchIsPaying degrades to null, never throws — see
131
+ // below). This is intentionally a thin, optional gate: engine/server.mjs
132
+ // still works standalone (e.g. for Collaborators / dev) with no billing-api
133
+ // configured at all.
134
+ const BILLING_API_URL = (process.env.MANAGER_BILLING_API_URL ?? '').replace(/\/$/, '');
135
+ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
136
+
137
+ // License token (local-first identity, docs/BILLING-LAUNCH-PLAN.md): a signed
138
+ // proof-of-identity the app obtains automatically via "Sign in with Google"
139
+ // (the loopback OAuth flow — /api/signin-url → billing /connect → Google →
140
+ // /oauth/callback below — hands it back with no copy-paste). Verified OFFLINE
141
+ // (verifyLicenseToken, Ed25519) so it works even when billing-api is
142
+ // unreachable — it proves WHO is asking (their verified email), not whether
143
+ // they're paying right now (that's still a live GET /entitlement, cached — see
144
+ // requireEntitlementGate below). Persisted as plain text next to secret.key so
145
+ // it survives restarts.
146
+ const licenseFile = join(DATA_DIR, 'license.token');
147
+ let licenseState = { email: null, isPaying: false, verifiedAt: 0 }; // in-memory cache of the last successful verify (isPaying is the token's snapshot; the live gate re-checks)
148
+
149
+ // One-time nonces for the "Sign in with Google" loopback (GET /api/signin-url →
150
+ // billing /connect → Google → billing → GET /oauth/callback). The engine issues
151
+ // the nonce, round-trips it through the flow, and requires it back — so a random
152
+ // page can't drive the local /oauth/callback with a token the user never asked for.
153
+ const SIGNIN_NONCE_TTL_MS = 10 * 60 * 1000;
154
+ const pendingSignins = new Map(); // nonce -> expiresAt
155
+ function newSigninNonce() {
156
+ const now = Date.now();
157
+ for (const [k, exp] of pendingSignins) if (exp < now) pendingSignins.delete(k); // opportunistic sweep
158
+ const nonce = randomBytes(18).toString('base64url');
159
+ pendingSignins.set(nonce, now + SIGNIN_NONCE_TTL_MS);
160
+ return nonce;
161
+ }
162
+ function consumeSigninNonce(nonce) {
163
+ const exp = pendingSignins.get(nonce);
164
+ if (exp === undefined) return false;
165
+ pendingSignins.delete(nonce);
166
+ return exp >= Date.now();
167
+ }
168
+
169
+ async function loadLicenseFromDisk() {
170
+ if (!existsSync(licenseFile)) return;
171
+ const token = readFileSync(licenseFile, 'utf8').trim();
172
+ if (!token) return;
173
+ const result = await verifyLicenseToken({ token });
174
+ if (result.valid) licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
175
+ }
176
+ loadLicenseFromDisk();
177
+
178
+ // Called by POST /api/license. Verifies before persisting — never write a
179
+ // token to disk that doesn't actually verify, so a bad paste can't silently
180
+ // wedge the app into "licensed but broken".
181
+ async function activateLicense(token) {
182
+ const result = await verifyLicenseToken({ token: String(token ?? '') });
183
+ if (!result.valid) return { ok: false, error: `invalid license: ${result.reason}` };
184
+ writeFileSync(licenseFile, String(token));
185
+ licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
186
+ return { ok: true, email: result.payload.email };
187
+ }
188
+
189
+ // The HTML shown in the popup after Google sign-in. It notifies the app window
190
+ // (postMessage; the app validates origin+shape and refreshes) and closes
191
+ // itself. Everything is inline + self-contained — this page can be opened
192
+ // cross-origin (127.0.0.1 vs the app's localhost) so it never touches the app DOM.
193
+ function signinResultPage(outcome) {
194
+ const esc = (s) => String(s ?? '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
195
+ const payload = JSON.stringify(outcome.ok ? { galda: 'signed-in', email: outcome.email } : { galda: 'signin-error', error: outcome.error });
196
+ const body = outcome.ok
197
+ ? `<h1>Signed in</h1><p>You're signed in as <b>${esc(outcome.email)}</b>. This tab will close automatically.</p>`
198
+ : `<h1>Sign-in didn't complete</h1><p>${esc(outcome.error)}</p><p class="muted">Close this tab and click “Sign in with Google” again in the app.</p>`;
199
+ return `<!doctype html><meta charset="utf-8"><title>Galda</title>` +
200
+ `<body style="font:15px/1.6 -apple-system,system-ui,sans-serif;max-width:32rem;margin:12vh auto;padding:0 1.5rem;color:#111">` +
201
+ `<style>h1{font-size:1.35rem;margin:0 0 .5rem}.muted{color:#888;font-size:.9rem}b{font-weight:600}</style>` +
202
+ body +
203
+ `<script>try{if(window.opener)window.opener.postMessage(${payload},'*')}catch(e){}` +
204
+ (outcome.ok ? `setTimeout(function(){try{window.close()}catch(e){}},1200);` : '') +
205
+ `</script></body>`;
206
+ }
207
+
208
+ // "Currently occupying capacity" for the free-tier pending-goal cap — every
209
+ // status short of a genuinely finished/dismissed one. Deliberately broad for
210
+ // the same reason RATE_LIMIT_RE above is broad: undercounting here would let
211
+ // someone quietly exceed the cap via an odd status, which costs more than
212
+ // occasionally counting a goal that's nearly done.
213
+ const OPEN_GOAL_STATUSES = ['stacked', 'sending', 'pending', 'planning', 'needsInput', 'running', 'partial', 'failed', 'interrupted', 'blocked', 'review', 'retesting'];
214
+
215
+ // NOTE(billing, needs a product decision): `projectCount` is left at 0 —
216
+ // projects are shared/global in this codebase today (see the "Projects ...
217
+ // stay shared for everyone" comment above goalVisibleTo in lib.mjs), not
218
+ // per-identity, so a per-user "max 1 project" cap has nothing correct to
219
+ // gate yet (an unpaid user's 2nd project would affect every other user, not
220
+ // just them). checkFreeTierLimit already supports projectCount > 1 the
221
+ // moment that's resolved — see docs/BILLING-LAUNCH-PLAN.md.
222
+ function computeFreeTierUsage(identity) {
223
+ const owned = goals.filter((g) => (g.owner ?? 'owner') === identity);
224
+ return {
225
+ pendingCount: owned.filter((g) => OPEN_GOAL_STATUSES.includes(g.status)).length,
226
+ cumulativeCount: owned.length,
227
+ projectCount: 0,
228
+ };
229
+ }
230
+
231
+ // Best-effort GET /entitlement?email=... against billing-api. Never throws —
232
+ // an unreachable/unconfigured billing-api must not crash goal creation, it
233
+ // just means requireEntitlementGate falls back to the cache (or fail-closed
234
+ // with no cache — see resolveCachedEntitlement in lib.mjs).
235
+ async function fetchIsPaying(email) {
236
+ if (!BILLING_API_URL) return null;
237
+ const controller = new AbortController();
238
+ const timeout = setTimeout(() => controller.abort(), 3000);
239
+ try {
240
+ const res = await fetch(`${BILLING_API_URL}/entitlement?email=${encodeURIComponent(email)}`, { signal: controller.signal });
241
+ if (!res.ok) return null;
242
+ const data = await res.json();
243
+ return Boolean(data?.isPaying);
244
+ } catch {
245
+ return null;
246
+ } finally {
247
+ clearTimeout(timeout);
248
+ }
249
+ }
250
+
251
+ // POST to billing-api from the engine so the browser never calls the Worker
252
+ // cross-origin (no CORS, and the paying email is the server-verified one, not
253
+ // a client-supplied value). Never throws — returns { ok, status, ...data }.
254
+ async function billingPost(path, payload) {
255
+ if (!BILLING_API_URL) return { ok: false, status: 501, error: 'billing not configured (MANAGER_BILLING_API_URL unset)' };
256
+ const controller = new AbortController();
257
+ const timeout = setTimeout(() => controller.abort(), 8000);
258
+ try {
259
+ const res = await fetch(`${BILLING_API_URL}${path}`, {
260
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal,
261
+ });
262
+ const data = await res.json().catch(() => ({}));
263
+ return { ok: res.ok, status: res.status, ...data };
264
+ } catch {
265
+ return { ok: false, status: 502, error: 'billing-api unreachable' };
266
+ } finally {
267
+ clearTimeout(timeout);
268
+ }
269
+ }
270
+
271
+ // The subscription price (from Stripe via billing-api /price), cached 1h so
272
+ // /api/state doesn't hit the Worker on every poll. null until first fetched or
273
+ // when billing-api is unconfigured/unreachable — the UI then omits the amount.
274
+ let billingPriceCache = { at: 0, price: null };
275
+ async function getBillingPrice() {
276
+ if (!BILLING_API_URL) return null;
277
+ const now = Date.now();
278
+ if (billingPriceCache.price && now - billingPriceCache.at < 3600_000) return billingPriceCache.price;
279
+ const controller = new AbortController();
280
+ const timeout = setTimeout(() => controller.abort(), 3000);
281
+ try {
282
+ const res = await fetch(`${BILLING_API_URL}/price`, { signal: controller.signal });
283
+ if (!res.ok) return billingPriceCache.price;
284
+ const data = await res.json();
285
+ if (data?.price) billingPriceCache = { at: now, price: data.price };
286
+ return billingPriceCache.price;
287
+ } catch {
288
+ return billingPriceCache.price;
289
+ } finally {
290
+ clearTimeout(timeout);
291
+ }
292
+ }
293
+
294
+ // The gate itself (BILLING-LAUNCH-PLAN.md §2/§5): within the free tier
295
+ // passes with zero network calls; only once computeFreeTierUsage says
296
+ // they're over it do we need to know isPaying, and even then we only hit
297
+ // billing-api when the cache says we must (resolveCachedEntitlement's
298
+ // needsRefresh) — a fresh "yes" skips the round trip on every subsequent
299
+ // over-the-limit request from the same identity.
300
+ //
301
+ // `identity` (CF Access / key-auth — usually 'owner' under the local-first
302
+ // public build, see licenseState above) is used ONLY for usage counting
303
+ // (computeFreeTierUsage), since goal ownership is stamped with it and a
304
+ // single local install has exactly one such counting bucket regardless of
305
+ // whether it's licensed yet. The isPaying LOOKUP uses licenseState.email
306
+ // instead — the proven-by-signature paid address — because billing-api has
307
+ // no idea who "owner" is. No verified license on file → blocked as
308
+ // not-paying, same as any other never-checked identity.
309
+ //
310
+ // Deliberately does NOT special-case `identity === 'owner'` as an unlimited
311
+ // bypass. That was correct only under Cloudflare Access (Stage 2 multi-user):
312
+ // there, `identityFor` resolves to 'owner' *specifically* for Masa's verified
313
+ // email or key-auth, and to a real distinct email for everyone else. Under
314
+ // the local-first public build (no Cloudflare Access — every request has no
315
+ // authEmail), `resolveIdentity` returns 'owner' for EVERY user, so an
316
+ // identity-string bypass would have silently disabled the free tier for
317
+ // every installation (~/.claude/plans/toasty-rolling-marshmallow.md).
318
+ // A personal/dev instance that wants to skip billing entirely should opt in
319
+ // explicitly via MANAGER_ENTITLEMENT_BYPASS=1, not ride the 'owner' string.
320
+ async function requireEntitlementGate(identity) {
321
+ if (process.env.MANAGER_ENTITLEMENT_BYPASS === '1') return { allowed: true, blocked: null };
322
+ // Count the goal being created (existing + 1): computeFreeTierUsage counts
323
+ // only goals already on file and this gate runs *before* the new one is
324
+ // added, so "5 pending / 19 lifetime max" must block the 6th / 20th create,
325
+ // not the 7th / 21st. (checkFreeTierLimit keeps its `> limit` semantics; we
326
+ // feed it the post-create count. Display in /api/state stays the raw count.)
327
+ const existing = computeFreeTierUsage(identity);
328
+ const usage = { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1 };
329
+ if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
330
+ const billingEmail = licenseState.email;
331
+ if (!billingEmail) return resolveEntitlement({ isOwner: false, isPaying: false, usage });
332
+ const cachedState = resolveCachedEntitlement({ cached: entitlementCache.get(billingEmail) ?? null });
333
+ let isPaying = cachedState.isPaying;
334
+ if (cachedState.needsRefresh) {
335
+ const fresh = await fetchIsPaying(billingEmail);
336
+ if (fresh != null) {
337
+ isPaying = fresh;
338
+ entitlementCache.set(billingEmail, { isPaying: fresh, checkedAt: Date.now() });
339
+ }
340
+ }
341
+ return resolveEntitlement({ isOwner: false, isPaying, usage });
342
+ }
343
+ // The acting identity for a request: 'owner' for key-auth or
344
+ // MANAGER_OWNER_EMAIL, else the verified Google email itself. Stamped onto
345
+ // new goals (owner) and used to filter/gate every goal-scoped read+write.
346
+ function identityFor(req) {
347
+ return resolveIdentity({ authEmail: req.authEmail, ownerEmail: MANAGER_OWNER_EMAIL });
348
+ }
349
+ // Goal-scoped endpoints (mutations, and the diff read) call this right after
350
+ // their `if (!goal) return json(res, 404, ...)` check: 403s a non-owner
351
+ // acting on a goal that isn't theirs (owner acts on everything; a normal
352
+ // user only on goals stamped with their own identity — see goalVisibleTo).
353
+ function requireGoalOwnership(req, res, goal) {
354
+ if (goalVisibleTo(goal, identityFor(req), MANAGER_OWNER_EMAIL)) return true;
355
+ json(res, 403, { error: 'forbidden' });
356
+ return false;
357
+ }
358
+ // Same check for a task-scoped endpoint (retry/snapshot/skip/priority): a
359
+ // task's ownership follows its goal's. `goals` is declared with `let`
360
+ // further down (module state) so this closes over the live array, not a
361
+ // snapshot frozen at definition time.
362
+ function requireTaskGoalOwnership(req, res, task) {
363
+ const goal = goals.find((g) => g.id === task.goalId);
364
+ // A task with no resolvable goal is orphaned data, not a real ownership
365
+ // gap — never reject solely for that (pre-existing behaviour elsewhere
366
+ // treats a missing goal permissively too).
367
+ if (!goal) return true;
368
+ return requireGoalOwnership(req, res, goal);
369
+ }
370
+ let cfAccessJwksCache = null;
371
+ let cfAccessJwksFetchedAt = 0;
372
+ const cfAccessLoggedEmails = new Set();
373
+
374
+ async function fetchCfAccessJwks({ forceRefresh = false } = {}) {
375
+ const now = Date.now();
376
+ if (!forceRefresh && cfAccessJwksCache && (now - cfAccessJwksFetchedAt) < CF_ACCESS_JWKS_TTL_MS) {
377
+ return cfAccessJwksCache;
378
+ }
379
+ const res = await fetch(`https://${CF_ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs`);
380
+ if (!res.ok) throw new Error(`CF Access JWKS fetch failed: HTTP ${res.status}`);
381
+ cfAccessJwksCache = await res.json();
382
+ cfAccessJwksFetchedAt = now;
383
+ return cfAccessJwksCache;
384
+ }
385
+
386
+ // Returns the authed email, or null (never throws — a JWKS fetch hiccup or
387
+ // a bad token just falls through to the ?key= gate below).
388
+ async function authFromCfAccess(req) {
389
+ if (!CF_ACCESS_ENABLED) return null;
390
+ const token = req.headers['cf-access-jwt-assertion'];
391
+ if (!token) return null;
392
+ try {
393
+ let jwks = await fetchCfAccessJwks();
394
+ let result = verifyCfAccessJwt({ token, jwks, aud: CF_ACCESS_AUD });
395
+ if (!result.ok && result.reason === 'unknown-kid') {
396
+ // key rotation: refetch once before giving up
397
+ jwks = await fetchCfAccessJwks({ forceRefresh: true });
398
+ result = verifyCfAccessJwt({ token, jwks, aud: CF_ACCESS_AUD });
399
+ }
400
+ if (!result.ok) return null;
401
+ if (!cfAccessLoggedEmails.has(result.email)) {
402
+ cfAccessLoggedEmails.add(result.email);
403
+ console.log(`[manager] Cloudflare Access authed: ${result.email}`);
404
+ }
405
+ return result.email;
406
+ } catch (e) {
407
+ console.error(`[manager] Cloudflare Access verification error: ${e.message ?? e}`);
408
+ return null;
409
+ }
410
+ }
411
+
412
+ const projectsFile = join(DATA_DIR, 'projects.json');
413
+ if (!existsSync(projectsFile)) {
414
+ const cwd = process.cwd();
415
+ writeFileSync(projectsFile, JSON.stringify([{ id: 'default', name: basename(cwd), dir: cwd }], null, 2));
416
+ }
417
+ const projects = JSON.parse(readFileSync(projectsFile, 'utf8'));
418
+ function saveProjects() { writeFileSync(projectsFile, JSON.stringify(projects, null, 2)); }
419
+ function uniqueProjectId(name = 'project') {
420
+ const base = String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 28) || 'project';
421
+ let id = base, n = 2;
422
+ while (projects.some((p) => p.id === id)) id = `${base}-${n++}`;
423
+ return id;
424
+ }
425
+ const logDir = join(DATA_DIR, 'chat-runs');
426
+ mkdirSync(logDir, { recursive: true });
427
+ const logFile = join(logDir, 'tasks.jsonl');
428
+
429
+ // Per-project workflow columns (task 44: the Tasks-panel column editor).
430
+ // Column names/order are cosmetic; every column still maps to one of the
431
+ // four buckets goalGroupStatus/goalChip actually track (see lib.mjs).
432
+ const workflowColumnsFile = join(DATA_DIR, 'workflow-columns.json');
433
+ let workflowColumns = existsSync(workflowColumnsFile) ? JSON.parse(readFileSync(workflowColumnsFile, 'utf8')) : {};
434
+ function saveWorkflowColumns() { writeFileSync(workflowColumnsFile, JSON.stringify(workflowColumns, null, 2)); }
435
+ function getColumns(projectId) { return workflowColumns[projectId] ?? DEFAULT_WORKFLOW_COLUMNS; }
436
+
437
+ // Per-project review definition (task 45): configurable conditions for when
438
+ // a finished goal lands in 'review' vs 'done'/'partial' — see lib.mjs.
439
+ const reviewDefinitionsFile = join(DATA_DIR, 'review-definitions.json');
440
+ let reviewDefinitions = existsSync(reviewDefinitionsFile) ? JSON.parse(readFileSync(reviewDefinitionsFile, 'utf8')) : {};
441
+ function saveReviewDefinitions() { writeFileSync(reviewDefinitionsFile, JSON.stringify(reviewDefinitions, null, 2)); }
442
+ function getReviewDefinition(projectId) { return reviewDefinitions[projectId] ?? DEFAULT_REVIEW_DEFINITION; }
443
+
444
+ // ---- state ---------------------------------------------------------------
445
+ // Restarts must not lose pending work: tasks that never started stay
446
+ // queued; only mid-run work becomes 'interrupted' (retryable). Goals that
447
+ // were still planning go back to the stack and re-plan. See replayQueueLog
448
+ // in lib.mjs for the (unit-tested) replay logic.
449
+ const replayed = replayQueueLog(existsSync(logFile) ? readFileSync(logFile, 'utf8') : '', reviewDefinitions);
450
+ let goals = replayed.goals;
451
+ let tasks = replayed.tasks;
452
+ let nextId = replayed.nextId;
453
+ let prioCounter = replayed.prioCounter;
454
+
455
+ // Reap goals a dead server left with no live worker (Masa dogfood: the To Do
456
+ // pile-up from a goal that never actually resumes) — see reconcileOrphanGoals
457
+ // in lib.mjs for the exact rule (unit-tested there). Computed and applied to
458
+ // the in-memory goal objects right here, right after replay, on purpose —
459
+ // BEFORE the MANAGER_SEED_RUNNING_FIXTURE block below adds its deliberately
460
+ // permanent 'running' example goal (that fixture is verify-instance-only
461
+ // scaffolding and must never be reclassified). The actual persistence
462
+ // (append to the restart-safe log + SSE) happens a little further down, once
463
+ // saveGoal() exists — see "orphanFixes.length" just below its definition.
464
+ const orphanFixes = reconcileOrphanGoals(goals, tasks);
465
+ for (const fix of orphanFixes) {
466
+ const g = goals.find((x) => x.id === fix.id);
467
+ if (g) { g.status = fix.status; g.blocked = null; g.prError = fix.reason; }
468
+ }
469
+
470
+ // Verify fixture only (task 49): a fresh ephemeral verify instance
471
+ // (startEphemeralApp) has no real running worker, but the pass condition
472
+ // under test is about how a genuinely 'running' task's live log renders.
473
+ // replayQueueLog always flips a replayed 'running' task to 'interrupted'
474
+ // (correct for real crash recovery — a real worker process didn't survive
475
+ // the restart), so a running example can't be seeded through the queue
476
+ // log. Instead, when this env var is set (only by startEphemeralApp), add
477
+ // one in-memory-only running task with a long activity log directly —
478
+ // after replay, so the flip above never touches it, and never written to
479
+ // logFile, so it doesn't leak into this throwaway instance's own restarts.
480
+ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
481
+ const at = new Date().toISOString();
482
+ const goalId = nextId++, taskId = nextId++;
483
+ goals.push({
484
+ id: goalId, projectId: 'default', text: '[verify fixture] 実行中ゴール', status: 'running',
485
+ wantsPR: false, model: 'sonnet', images: [], plan: ['fixture task'], pr: undefined, createdAt: at,
486
+ });
487
+ tasks.push({
488
+ id: taskId, num: 1, goalId, projectId: 'default', title: 'fixture task', detail: '', passCondition: '',
489
+ model: 'sonnet', status: 'running', createdAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
490
+ activity: [
491
+ 'Read app/index.html', 'Grep taskBlock', 'Edit app/index.html', 'Read engine/lib.mjs',
492
+ 'Bash node --test engine/test/*.test.mjs', '✓ 12 tests passed', 'Edit engine/server.mjs',
493
+ 'Read engine/test/lib.test.mjs', 'Edit engine/test/lib.test.mjs',
494
+ 'Bash node --test engine/test/*.test.mjs', '✓ 13 tests passed', '✎ 実装をまとめています…',
495
+ ],
496
+ });
497
+ }
498
+
499
+ // res -> identity (see identityFor), captured at connection time so every
500
+ // SSE push can be scoped per-connection without re-deriving auth per event.
501
+ const sseClients = new Map();
502
+ // UI hot-reload: open tabs compare this stamp (index.html mtime + boot
503
+ // time) via /api/state and the 30s SSE ping, and reload themselves when a
504
+ // new UI ships — no manual reload after a hot-copy or a restart.
505
+ const BOOT_STAMP = Date.now();
506
+ function uiVersion() {
507
+ try { return `${Math.round(statSync(join(ROOT, 'app', 'index.html')).mtimeMs)}-${BOOT_STAMP}`; }
508
+ catch { return String(BOOT_STAMP); }
509
+ }
510
+
511
+ // Broadcast to every connected client, unscoped — only for events that carry
512
+ // no per-goal data (project-level columns/review-definition, the keepalive
513
+ // ping). Goal/task events go through sendGoalEvent instead (stage 2).
514
+ function send(obj) {
515
+ const data = `data: ${JSON.stringify(obj)}\n\n`;
516
+ for (const res of sseClients.keys()) res.write(data);
517
+ }
518
+ // Push a goal-scoped event only to the connections whose identity can see
519
+ // that goal (owner gets everything; a normal user only their own — same
520
+ // rule as /api/state's filtering, see goalVisibleTo in lib.mjs). This is how
521
+ // a Google user's SSE stream never observes another user's goal/task
522
+ // activity even though the queue/workers are still shared infrastructure.
523
+ function sendGoalEvent(obj, goal) {
524
+ const data = `data: ${JSON.stringify(obj)}\n\n`;
525
+ for (const [res, identity] of sseClients) {
526
+ if (goalVisibleTo(goal, identity, MANAGER_OWNER_EMAIL)) res.write(data);
527
+ }
528
+ }
529
+ function saveGoal(g) { appendFileSync(logFile, JSON.stringify({ ...g, kind: 'goal' }) + '\n'); sendGoalEvent({ ev: 'goal', goal: g }, g); }
530
+ // Persist the orphan-goal reclassification computed right after replayQueueLog
531
+ // above (deferred to here because saveGoal/send/sseClients don't exist yet at
532
+ // that point in the module) — writes it to the restart-safe log so it settles
533
+ // for good, not just in this process's memory, and broadcasts it over SSE for
534
+ // any UI that's already connected.
535
+ for (const fix of orphanFixes) {
536
+ const g = goals.find((x) => x.id === fix.id);
537
+ if (g) { console.log(`[manager] goal ${g.id}: reaped as orphaned (no live worker after restart) → interrupted`); saveGoal(g); }
538
+ }
539
+ function saveTask(t) {
540
+ const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
541
+ appendFileSync(logFile, JSON.stringify({ ...persist, kind: 'task' }) + '\n');
542
+ const goal = goals.find((g) => g.id === t.goalId);
543
+ if (goal) sendGoalEvent({ ev: 'task', task: persist }, goal); else send({ ev: 'task', task: persist });
544
+ }
545
+ const ACTIVITY_LIMIT = 1000;
546
+ function activityLine(line) {
547
+ return String(line ?? '')
548
+ .replace(/\s+/g, ' ')
549
+ .replace(/key=[^&\s"']+/gi, 'key=***')
550
+ .slice(0, 500);
551
+ }
552
+ function sendAct(t, line) {
553
+ const safe = activityLine(line);
554
+ if (!safe) return;
555
+ t.activity = [...(t.activity ?? []), safe].slice(-ACTIVITY_LIMIT);
556
+ const goal = goals.find((g) => g.id === t.goalId);
557
+ if (goal) sendGoalEvent({ ev: 'act', taskId: t.id, line: safe }, goal); else send({ ev: 'act', taskId: t.id, line: safe });
558
+ }
559
+ function sendProcessAct(t, line) {
560
+ sendAct(t, `✎ ${line}`);
561
+ }
562
+ // The worker's live to-do list (from Claude Code's TodoWrite): each call
563
+ // replaces the whole list, so we just overwrite. Ephemeral like activity —
564
+ // held on the task only while it runs, streamed to the UI, never persisted.
565
+ function sendTodos(t, todos) {
566
+ t.todos = todos;
567
+ const goal = goals.find((g) => g.id === t.goalId);
568
+ if (goal) sendGoalEvent({ ev: 'todos', taskId: t.id, todos }, goal); else send({ ev: 'todos', taskId: t.id, todos });
569
+ }
570
+
571
+ // ---- claude helpers --------------------------------------------------------
572
+ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort, permissionMode = 'acceptEdits', onChild }) {
573
+ return new Promise((done) => {
574
+ const args = [
575
+ '-p', prompt,
576
+ '--model', MODELS.includes(model) ? model : 'sonnet',
577
+ '--effort', workerEffort('claude-code', effort),
578
+ '--permission-mode', permissionMode,
579
+ '--allowedTools', tools,
580
+ '--output-format', 'stream-json', '--verbose',
581
+ ];
582
+ if (resume) args.push('--resume', resume);
583
+ const child = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
584
+ // Expose the spawned child so a goal can be cancelled (deleted while
585
+ // running) by SIGTERMing exactly its own worker — see trackGoalWorker.
586
+ onChild?.(child);
587
+ let result = '', err = '', buf = '', sessionId = null, usage = null, timedOut = false, lastAct = '';
588
+ // Mark WHY the child died: the timeout SIGTERMs the worker (exit 143), which
589
+ // is a forced STOP, not a code failure — the caller uses timedOut to mark the
590
+ // task 'interrupted' with a real reason instead of a bare 'failed / (no output)'.
591
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, WORKER_TIMEOUT_MS);
592
+ child.stdout.on('data', (d) => {
593
+ buf += d;
594
+ const lines = buf.split('\n'); buf = lines.pop();
595
+ for (const line of lines) {
596
+ for (const ev of parseStreamEvents(line)) {
597
+ if (ev.kind === 'result') { result = ev.text; usage = ev.usage; }
598
+ else if (ev.kind === 'session') sessionId = ev.id;
599
+ else if (ev.kind === 'todos') onTodos?.(ev.todos);
600
+ else { if (ev.text?.trim()) lastAct = ev.text.trim(); onEvent?.(ev.text); }
601
+ }
602
+ }
603
+ });
604
+ child.stderr.on('data', (d) => { err += d; });
605
+ child.on('close', (code) => {
606
+ clearTimeout(timer);
607
+ done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId, usage });
608
+ });
609
+ });
610
+ }
611
+ function parseCodexUsage(usage) {
612
+ if (!usage) return null;
613
+ return {
614
+ input_tokens: usage.input_tokens ?? usage.inputTokens ?? 0,
615
+ output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
616
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
617
+ cache_read_input_tokens: usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0,
618
+ };
619
+ }
620
+ function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model, effort, onChild }) {
621
+ return new Promise((done) => {
622
+ const sandbox = permissionMode === 'plan' ? 'read-only' : 'workspace-write';
623
+ const args = ['exec', '--json', '-m', workerModel('codex', model), '-c', `model_reasoning_effort="${workerEffort('codex', effort)}"`, '--sandbox', sandbox, '--skip-git-repo-check', '--cd', cwd, '-'];
624
+ const child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
625
+ onChild?.(child);
626
+ child.stdin.end(prompt);
627
+ let result = '', err = '', buf = '', usage = null, timedOut = false, lastAct = '';
628
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, WORKER_TIMEOUT_MS);
629
+ child.stdout.on('data', (d) => {
630
+ buf += d;
631
+ const lines = buf.split('\n'); buf = lines.pop();
632
+ for (const line of lines) {
633
+ if (!line.trim()) continue;
634
+ try {
635
+ const ev = JSON.parse(line);
636
+ if (ev.type === 'item.completed') {
637
+ const item = ev.item ?? {};
638
+ if (item.type === 'agent_message' && item.text) {
639
+ result = item.text;
640
+ onEvent?.(`✎ ${item.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
641
+ } else if (item.type === 'error' && item.message) onEvent?.(`Error ${item.message}`);
642
+ else if (item.type === 'command_execution' && item.command) { lastAct = `Run ${item.command}`; onEvent?.(lastAct); }
643
+ } else if (ev.type === 'turn.completed') {
644
+ usage = parseCodexUsage(ev.usage);
645
+ } else if (ev.type === 'error' && ev.message) {
646
+ err += `${ev.message}\n`;
647
+ onEvent?.(`Error ${ev.message}`);
648
+ }
649
+ } catch {
650
+ if (line.trim()) lastAct = line.trim().slice(0, 240);
651
+ onEvent?.(line.trim().slice(0, 240));
652
+ }
653
+ }
654
+ });
655
+ child.stderr.on('data', (d) => { err += d; });
656
+ child.on('close', (code) => {
657
+ clearTimeout(timer);
658
+ done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId: null, usage });
659
+ });
660
+ });
661
+ }
662
+ function runWorkerAgent({ agent, ...args }) {
663
+ return workerAgent(agent) === 'codex' ? runCodex(args) : runClaude(args);
664
+ }
665
+
666
+ // Track worker (and planner) children per goal so a goal can be cancelled by
667
+ // SIGTERMing exactly its own child(ren) — never another goal's, and never a
668
+ // user process. The child auto-unregisters on close.
669
+ const goalWorkerChildren = new Map(); // goalId -> Set<ChildProcess>
670
+ function trackGoalWorker(goalId, child) {
671
+ if (goalId == null || !child) return;
672
+ let set = goalWorkerChildren.get(goalId);
673
+ if (!set) { set = new Set(); goalWorkerChildren.set(goalId, set); }
674
+ set.add(child);
675
+ child.on('close', () => { set.delete(child); if (!set.size) goalWorkerChildren.delete(goalId); });
676
+ }
677
+ function killGoalWorkers(goalId) {
678
+ const set = goalWorkerChildren.get(goalId);
679
+ if (!set) return 0;
680
+ let n = 0;
681
+ for (const c of set) { try { c.kill('SIGTERM'); n++; } catch { /* already gone */ } }
682
+ goalWorkerChildren.delete(goalId);
683
+ return n;
684
+ }
685
+
686
+ // Cancel a goal's in-flight work for deletion: mark it cancelled (runTask/
687
+ // planGoal bail before verify/PR), SIGTERM its worker/planner child(ren), drop
688
+ // its still-queued tasks from the project queue so pump() never starts them,
689
+ // and mark its unfinished tasks 'cancelled'. Only the Manager-spawned child is
690
+ // killed; the user's working tree is never touched (the goal's own .manager-wt
691
+ // worktree is left in place — safe, and reclaimed by the normal paths).
692
+ function cancelGoalWork(goal) {
693
+ goal.cancelled = true;
694
+ killGoalWorkers(goal.id);
695
+ const q = queues.get(goal.projectId);
696
+ if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
697
+ for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'running', 'interrupted'].includes(t.status))) {
698
+ t.status = 'cancelled'; t.finishedAt = new Date().toISOString(); saveTask(t);
699
+ }
700
+ }
701
+
702
+ function gitChanges(dir) {
703
+ return new Promise((res) => {
704
+ execFile('git', ['-C', dir, 'status', '--porcelain', '-uall'], (e, out) =>
705
+ res(e ? [] : out.split('\n').filter(Boolean).slice(0, 60)));
706
+ });
707
+ }
708
+
709
+ function gitSync(cwd, args) {
710
+ const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
711
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || r.stdout || '').trim().slice(0, 200)}`);
712
+ return r.stdout.trim();
713
+ }
714
+ function repoDefaultBranch(dir) {
715
+ try { return gitSync(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, ''); }
716
+ catch { return 'main'; }
717
+ }
718
+ // Each goal works in its OWN git worktree, branched fresh from origin/<default>,
719
+ // under <repo>/.manager-wt/goal-<id>. So: (1) the user's real working dir is
720
+ // never touched; (2) each goal's diff contains only that goal's changes on top
721
+ // of current main → a clean PR (no stale base, no other goals' edits mixed in).
722
+ // Reused across a goal's tasks and its rework replies; falls back to project.dir
723
+ // if worktrees can't be created (e.g. not a git repo).
724
+ const goalWorkDirs = new Map();
725
+ function goalWorkDir(goal, project) {
726
+ const cached = goalWorkDirs.get(goal.id);
727
+ if (cached && existsSync(cached)) return cached;
728
+ let repoRoot;
729
+ try { repoRoot = gitSync(project.dir, ['rev-parse', '--show-toplevel']); }
730
+ catch { return project.dir; } // not a git repo — behave as before
731
+ const br = repoDefaultBranch(project.dir);
732
+ const wt = join(repoRoot, '.manager-wt', `goal-${goal.id}`);
733
+ const branch = `manager-ai/goal-${goal.id}`;
734
+ try { gitSync(repoRoot, ['fetch', 'origin', br, '--quiet']); } catch { /* offline — use whatever origin/<br> we have */ }
735
+ if (!existsSync(wt)) {
736
+ try { gitSync(repoRoot, ['worktree', 'add', wt, '-b', branch, `origin/${br}`]); }
737
+ catch {
738
+ try { gitSync(repoRoot, ['worktree', 'add', wt, branch]); } // branch already exists (rework) — reuse it
739
+ catch { return project.dir; } // give up cleanly rather than block the worker
740
+ }
741
+ }
742
+ goalWorkDirs.set(goal.id, wt);
743
+ return wt;
744
+ }
745
+ function removeGoalWorkDir(goal, project) {
746
+ const wt = goalWorkDirs.get(goal.id);
747
+ if (!wt) return;
748
+ goalWorkDirs.delete(goal.id);
749
+ try { const repoRoot = gitSync(project.dir, ['rev-parse', '--show-toplevel']); gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
750
+ }
751
+
752
+ // External commit sync: pick up commits made outside this Manager (Claude
753
+ // Code sessions run directly in the terminal, manual commits, other tools)
754
+ // so they show up alongside Manager-run tasks for the same project. The
755
+ // last-seen hash per project is persisted so a server restart doesn't
756
+ // re-announce commits we've already synced.
757
+ const externalSyncFile = join(ROOT, 'engine', 'external-sync.json');
758
+ let externalSync = {};
759
+ if (existsSync(externalSyncFile)) {
760
+ try { externalSync = JSON.parse(readFileSync(externalSyncFile, 'utf8')); } catch {}
761
+ }
762
+ function saveExternalSync() {
763
+ writeFileSync(externalSyncFile, JSON.stringify(externalSync, null, 2));
764
+ }
765
+
766
+ function gitLogRaw(dir) {
767
+ return new Promise((res) => {
768
+ execFile('git', ['-C', dir, 'log', '--pretty=format:%H%x1f%ad%x1f%an%x1f%s', '--date=iso-strict', '-n', '30'],
769
+ (e, out) => res(e ? '' : out));
770
+ });
771
+ }
772
+
773
+ async function syncExternalCommits(project) {
774
+ const commits = parseGitLog(await gitLogRaw(project.dir));
775
+ if (!commits.length) return;
776
+ const prev = externalSync[project.id];
777
+ const fresh = diffNewCommits(commits, prev?.lastHash ?? null);
778
+ if (fresh.length) console.log(`[manager] ${project.id}: ${fresh.length} new commit(s) synced`);
779
+ externalSync[project.id] = { lastHash: commits[0].hash, commits: commits.slice(0, 20) };
780
+ saveExternalSync();
781
+ }
782
+
783
+ async function syncAllExternal() {
784
+ await Promise.all(projects.map((p) => syncExternalCommits(p).catch(() => {})));
785
+ }
786
+
787
+ // Goals awaiting review carry their PR url in goal.pr; poll `gh pr view` for
788
+ // each and flip 'review' -> 'done' once GitHub reports it merged or approved.
789
+ function ghPrView(url) {
790
+ return new Promise((res) => {
791
+ execFile('gh', ['pr', 'view', url, '--json', 'state,mergedAt,reviewDecision'], (e, out, stderr) => {
792
+ // Silent forever otherwise: if `gh` is missing/unauthenticated in prod,
793
+ // review goals would never auto-flip to done and nothing would say why.
794
+ if (e) console.warn(`[manager] gh pr view ${url} failed: ${(stderr || e.message || '').trim().slice(0, 300)}`);
795
+ res(e ? null : out);
796
+ });
797
+ });
798
+ }
799
+ async function syncGoalMerges() {
800
+ const reviewGoals = goals.filter((g) => g.status === 'review' && g.pr);
801
+ await Promise.all(reviewGoals.map(async (g) => {
802
+ const raw = await ghPrView(g.pr);
803
+ if (!raw) return;
804
+ let data;
805
+ try { data = JSON.parse(raw); } catch { return; }
806
+ const newStatus = reviewToDoneStatus({ goalStatus: g.status, merged: isPrMerged(data), approved: isPrApproved(data) });
807
+ if (newStatus !== g.status) {
808
+ g.status = newStatus; saveGoal(g);
809
+ // §3/§7: a merged PR is truly terminal (no rework possible) — reclaim the
810
+ // per-goal worktree so .manager-wt/goal-<id> doesn't leak forever. (Manual
811
+ // Approve stays reworkable via revert, so we don't clean those up here.)
812
+ if (newStatus === 'done' && isPrMerged(data)) {
813
+ const project = projects.find((p) => p.id === g.projectId);
814
+ if (project) removeGoalWorkDir(g, project);
815
+ }
816
+ }
817
+ }));
818
+ }
819
+
820
+ // ---- goal pipeline ---------------------------------------------------------
821
+ // Bounded per-project parallelism (Masa: "same project でも conflict しない
822
+ // ものは並列で実装してほしい。conflict 判断はしてほしい。"): each goal already
823
+ // runs in its own isolated git worktree (goalWorkDir above), so DIFFERENT
824
+ // goals' tasks can safely run at the same time — only a single goal's OWN
825
+ // tasks must stay sequential. `running` replaces the old single `busy`
826
+ // boolean with the SET of tasks currently in flight for that project;
827
+ // nextRunnableTasks() (engine/lib.mjs) decides which queued tasks are safe to
828
+ // start without disturbing same-goal ordering. MANAGER_MAX_PARALLEL caps how
829
+ // many of THIS project's tasks run at once (default 2, hard cap 4);
830
+ // MANAGER_GLOBAL_MAX caps the total across every project, so a burst of
831
+ // goals across many projects still can't spawn unbounded `claude` processes.
832
+ const PROJECT_MAX_PARALLEL = clampParallelLimit(process.env.MANAGER_MAX_PARALLEL, { def: 2, max: 4 });
833
+ const GLOBAL_MAX_PARALLEL = clampParallelLimit(process.env.MANAGER_GLOBAL_MAX, { def: 4, max: 32 });
834
+ const queues = new Map(projects.map((p) => [p.id, { running: new Set(), waiting: [] }]));
835
+ function globalRunningCount() {
836
+ let n = 0;
837
+ for (const q of queues.values()) n += q.running.size;
838
+ return n;
839
+ }
840
+
841
+ // Goals stack up per project: while one goal is being planned or executed,
842
+ // newer goals wait as 'stacked' (editable / deletable until picked up).
843
+ function goalActive(projectId) {
844
+ return goals.some((g) => g.projectId === projectId && ['planning', 'running'].includes(g.status));
845
+ }
846
+ function startNextStacked(projectId) {
847
+ if (goalActive(projectId)) return;
848
+ const next = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
849
+ .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id)[0];
850
+ if (!next) return;
851
+ next.status = 'planning';
852
+ saveGoal(next);
853
+ planGoal(next).catch((e) => {
854
+ next.status = 'failed'; next.prError = String(e).slice(0, 300); saveGoal(next);
855
+ startNextStacked(projectId);
856
+ });
857
+ }
858
+
859
+ async function planGoal(goal) {
860
+ const project = projects.find((p) => p.id === goal.projectId);
861
+ const prompt = [
862
+ 'あなたはユーザーの依頼を受けるプロダクトマネージャ。以下のユーザー依頼を、ユーザーから見た「成果物(トピック)」の単位で整理する。エンジニアリングの工程には分解しない。',
863
+ '- 既定は 1トピック=1タスク。1つのまとまった依頼を実装工程に割らない(「PRDを作る」「パーサを追加」「テストを書く」「リファクタ」等を別タスクにしない)。それらは1人のworkerが内部で全部やる(実装→テスト→proof/検証まで一気通貫)。',
864
+ '- 複数タスクに分けるのは、依頼に「明確に別々の成果物・トピック」が含まれる時「だけ」(例:「音声入力を追加して、あとビジュアル作成も」→ 音声入力 / ビジュアル作成 の2つ)。工程での分割は絶対にしない。',
865
+ '- 被っている・言い換えているだけの依頼は、自分で理解して1つにまとめる。',
866
+ '- タスク数は最小に。既定1個、多くても distinct な依頼の数だけ(現実的に1〜3、最大4)。依存順に並べる。',
867
+ '- 各タスクの title は「ユーザーから見た成果(ユーザーの言葉のまま・短く)」にする。エンジニアリングの手順名(設計/実装/テスト等)にしない。detail には worker への完全な指示を書く:そのworkerが1人で実装・テスト・proofまで完結させる。工程ごとの兄弟タスクは来ない前提で書くこと。',
868
+ '- passCondition はユーザーが依頼文で明示的に検証条件(「成功条件:」「〜したら〜になる」等)を書いた場合のみ設定する。見た目の調整・文言変更・スタイル変更のタスクには付けない(null)。検証は高価なので、明示された時だけ。',
869
+ '- 検証対象のページがあれば entry に書く(プロジェクト相対のhtmlパス、または http URL。無ければ null)。依頼中に書かれていればそれを使い、無ければリポジトリを見て推測してよい。',
870
+ '- 依頼が曖昧で重要な前提が1つ欠けている(例: 対象・範囲・形式が不明)か、既存の作業と矛盾する場合「のみ」、タスクではなく確認質問を返す: {"question":"確認したい1点(短く)","options":["選択肢A","選択肢B"]}。ただし基本は合理的な仮定で進めること。質問は本当に必要な時だけ、1問に絞る。',
871
+ // Once the user has answered a clarification, NEVER ask again — re-asking looped
872
+ // the answered question back onto the board. Override the rule above for re-plans.
873
+ goal.clarified ? '【最重要】ユーザーは既にこの依頼への確認質問に回答済み(依頼文末尾に [確認: … → …] として反映済み)。これ以上、確認質問(question)を返してはならない。回答を前提に、必ずタスクへ分解して返すこと。' : '',
874
+ '- 出力はJSONのみ(前後に文章を書かない)。通常: {"entry":"path|null","tasks":[{"title":"ユーザーから見た成果(短い日本語)","detail":"workerへの完全な指示","passCondition":"検証条件|null"}]} / 確認が要る時だけ: {"question":"...","options":["...","..."]}',
875
+ '',
876
+ goal.priorFailureMemory?.length ? [
877
+ '過去の失敗メモリ(同じ失敗を避けること):',
878
+ latestFailurePolicy(goal.priorFailureMemory)?.text,
879
+ 'この依頼を分解するときは、上記の防止策を detail に自然に含める。',
880
+ ].filter(Boolean).join('\n') : '',
881
+ '',
882
+ goal.images?.length ? `添付画像(Readで読める絶対パス): ${goal.images.join(' ')}` : '',
883
+ `依頼: ${goal.text}`,
884
+ ].filter(Boolean).join('\n');
885
+ // Plan (and later work) inside this goal's own worktree, so the warm session
886
+ // the workers resume shares the same cwd and the goal stays isolated.
887
+ const workDir = goalWorkDir(goal, project);
888
+ // Planner decomposes the goal into 1-6 tasks. (Tried haiku for speed but it was
889
+ // SLOWER — the cost is claude cold-start + repo reading, not inference, and
890
+ // haiku took more tool round-trips. The instant Planning… row is the real
891
+ // "it's queued" feedback; detailed tasks follow in ~13s.)
892
+ const { result, sessionId } = await runWorkerAgent({ agent: goal.agent, prompt, cwd: workDir, tools: 'Read,Glob,Grep', model: goal.model, effort: goal.effort, onChild: (c) => trackGoalWorker(goal.id, c) });
893
+ if (goal.cancelled || !goals.some((g) => g.id === goal.id)) return; // deleted/cancelled while planning
894
+ // Warm handoff: the planner already read the repo in this session, so the
895
+ // goal's workers resume it instead of cold-starting a fresh claude each
896
+ // task (faster first token, no re-briefing tokens).
897
+ if (sessionId) goal.sessionId = sessionId;
898
+ // Smart intake: the planner may return a clarifying question instead of tasks
899
+ // when the goal is genuinely ambiguous. Surface it as a "needsInput" goal (a
900
+ // ❓ card in To Do) and wait for the answer — no tasks created yet, no worker.
901
+ let clarify = null;
902
+ try { const m = result.match(/\{[\s\S]*\}/); if (m) { const o = JSON.parse(m[0]); if (o && o.question && !Array.isArray(o.tasks)) clarify = { text: String(o.question).slice(0, 300), options: Array.isArray(o.options) ? o.options.slice(0, 4).map((x) => String(x).slice(0, 60)) : [] }; } } catch { /* not a question */ }
903
+ // Ask a clarifying question ONLY on the first plan. Once the user has answered
904
+ // (goal.clarified), NEVER re-enter needsInput — that was the loop where an
905
+ // already-answered question kept reappearing on the board. A stray question on a
906
+ // re-plan is ignored and parsePlan falls back to a single task from goal.text
907
+ // (which now carries the answer). Structural guarantee: the code refuses to
908
+ // re-ask even if the planner disobeys the prompt.
909
+ if (shouldAskClarification(clarify, goal)) {
910
+ goal.question = clarify;
911
+ goal.status = 'needsInput';
912
+ saveGoal(goal);
913
+ return; // wait for /answer
914
+ }
915
+ goal.question = null;
916
+ const plan = parsePlan(result, goal.text);
917
+ // USER-TOPIC granularity backstop: collapse any engineering-phase over-
918
+ // decomposition the LLM may still emit into user-facing deliverables (Masa:
919
+ // プログラムエンジニアのタスク単位で分解しないで — 1メッセージ=1トピック既定).
920
+ plan.tasks = refinePlanTasks(plan.tasks, { goalText: goal.text });
921
+ goal.status = 'running';
922
+ goal.entry = plan.entry;
923
+ goal.plan = plan.tasks.map((t) => t.title);
924
+ saveGoal(goal);
925
+ const base = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
926
+ let num = (base.length ? Math.max(...base) : 0);
927
+ for (const p of plan.tasks) {
928
+ const task = {
929
+ id: nextId++, num: ++num, goalId: goal.id, projectId: goal.projectId,
930
+ title: p.title, detail: p.detail, passCondition: p.passCondition, model: goal.model, effort: goal.effort, agent: goal.agent,
931
+ mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
932
+ status: 'queued', createdAt: new Date().toISOString(), priority: '中',
933
+ result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
934
+ };
935
+ tasks.push(task);
936
+ saveTask(task);
937
+ queues.get(goal.projectId).waiting.push(task);
938
+ }
939
+ pump(goal.projectId);
940
+ }
941
+
942
+ // Author the executable check once per task (the only LLM cost of
943
+ // verification); the check itself then runs deterministically in Chrome.
944
+ async function authorVerify(task, goal, project) {
945
+ const file = join(logDir, `verify-task-${task.id}.mjs`);
946
+ const prompt = [
947
+ `You are writing an automated UI check. Read ${goal.entry} (and related files) to find real selectors.`,
948
+ ``,
949
+ `PASS CONDITION to check: ${task.passCondition}`,
950
+ ``,
951
+ 'IMPORTANT if the entry is this Manager app itself (app/index.html): this check runs against a FRESH EPHEMERAL instance, so a brand-new goal created there (compose box, or POST /api/tasks) does NOT queue harmlessly — it goes straight to \'planning\' and starts a REAL background `claude` process immediately, which takes far longer than any UI wait should sit through (never create a goal and then wait for its status to reach \'review\'/\'done\' — that will time out). Instead verify against state/UI that already exists: read window.state.tasks/goals for already-finished entries, or the current page\'s own render output. window.state.goals already contains four settled example goals seeded for exactly this purpose: one finished with a PR (wantsPR: true, pr set), one finished without one (wantsPR: false, no pr), one already sitting in \'review\' status (id 5, use it to check review-column display content e.g. a project\'s review description text, since app/index.html only renders that section when at least one goal is actually in \'review\'), and one goal+task that is genuinely status: \'running\' right now with a long (12-line) window.state-visible activity log already attached (the task\'s chat card in the stream area — NOT the small floating corner panel — is the one to inspect for full-log/no-truncation/auto-scroll behavior; it never finishes, so it is always safe to assert against without waiting). If you do need to check that a *newly created* goal\'s wantsPR/PR-related field comes out right, only read the field from the immediate creation response/state (it is set synchronously before any worker runs) — never wait on it progressing further.',
952
+ ``,
953
+ 'Output ONLY the JavaScript source of an ES module (no markdown fences, no prose) of this exact shape:',
954
+ 'export default async function verify(page, ui) {',
955
+ ' // page: full puppeteer Page. ui.click(selector): moves a visible cursor and clicks; returns false if the element is missing.',
956
+ ' // Interact like a user (ui.click for clicks; page.type/page.evaluate where needed), then assert the outcome.',
957
+ ' // Return { pass: boolean, detail: string } — detail says what was observed, in Japanese.',
958
+ '}',
959
+ ].join('\n');
960
+ const { result } = await runWorkerAgent({ agent: goal?.agent, prompt, cwd: project.dir, tools: 'Read,Glob,Grep', permissionMode: 'plan' });
961
+ const code = result.replace(/^```[a-z]*\n?/m, '').replace(/```\s*$/m, '').trim();
962
+ writeFileSync(file, code);
963
+ const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
964
+ if (typeof mod.default !== 'function') throw new Error('authored verify script has no default export');
965
+ return mod.default;
966
+ }
967
+
968
+ // goal 427 §3 — slow mode: when MANAGER_SLOW_MODE is on, pace task starts so
969
+ // an AFK run doesn't burn quota at hardware speed. Pure-ish (isolated to this
970
+ // one Map so it stays trivially inspectable); the actual gate is the
971
+ // `lastStartAt` check in pump() below.
972
+ const slowModeLastStart = new Map(); // projectId -> ms epoch of the last task start
973
+ function pump(projectId) {
974
+ const q = queues.get(projectId);
975
+ // sortQueueByPriority is a stable sort: it only ever moves a task ahead of
976
+ // a lower-priority one, never disturbs relative order within the same
977
+ // priority — so a task's push-time (creation) position, or a user's own
978
+ // drag reorder (/api/queue/reorder below), survives every pump() call.
979
+ q.waiting = sortQueueByPriority(q.waiting);
980
+ if (!q.waiting.length) return;
981
+ const slow = slowModeSettings();
982
+ // Slots = the tighter of "this project's own parallel cap" (further capped
983
+ // by slow mode's maxParallel when on) and "global cap across every
984
+ // project" — never spawn past either. nextRunnableTasks then picks which
985
+ // queued tasks are actually safe to start within that many slots: at most
986
+ // one per goalId not already running, so a goal's own next task never
987
+ // jumps ahead of its still-running sibling.
988
+ const projectCap = slow.enabled ? Math.min(PROJECT_MAX_PARALLEL, slow.maxParallel) : PROJECT_MAX_PARALLEL;
989
+ const slots = Math.min(projectCap - q.running.size, GLOBAL_MAX_PARALLEL - globalRunningCount());
990
+ if (!canStartMore(0, slots)) return;
991
+ if (slow.enabled) {
992
+ const last = slowModeLastStart.get(projectId) ?? 0;
993
+ const wait = slow.delayMs - (Date.now() - last);
994
+ if (wait > 0) { setTimeout(() => pump(projectId), wait); return; }
995
+ }
996
+ const runningGoalIds = [...q.running].map((t) => t.goalId);
997
+ const toStart = nextRunnableTasks(q.waiting, runningGoalIds, slow.enabled ? 1 : slots);
998
+ for (const task of toStart) {
999
+ const i = q.waiting.indexOf(task);
1000
+ if (i !== -1) q.waiting.splice(i, 1);
1001
+ q.running.add(task);
1002
+ if (slow.enabled) slowModeLastStart.set(projectId, Date.now());
1003
+ runTask(task).finally(() => { q.running.delete(task); pump(projectId); });
1004
+ }
1005
+ }
1006
+
1007
+ // goal 427 §1/§2 wiring: cumulative per-goal usage tracking + the two stop
1008
+ // conditions (run-budget exceeded / rate-limited) that a worker call can hit.
1009
+ // Called right after every runClaude() call that belongs to a goal's own
1010
+ // worker (not the cheap auxiliary calls — authorVerify/summarizeForReview —
1011
+ // which are single, haiku-tier, and not the actual quota-burn risk).
1012
+ function trackGoalRunUsage(goal, elapsedMs, usage) {
1013
+ if (!goal) return null;
1014
+ goal.runElapsedMs = (goal.runElapsedMs ?? 0) + Math.max(0, elapsedMs || 0);
1015
+ goal.runUsage = sumUsage([goal.runUsage, usage]);
1016
+ goal.runTokenWeight = CACHE_READ_TOKEN_WEIGHT;
1017
+ goal.runTokens = (goal.runTokens ?? 0) + usageBudgetTokens(usage, { cacheReadWeight: CACHE_READ_TOKEN_WEIGHT });
1018
+ goal.runAttempts = (goal.runAttempts ?? 0) + 1;
1019
+ const budget = checkRunBudget({ elapsedMs: goal.runElapsedMs, tokens: goal.runTokens, attempts: goal.runAttempts }, goalRunBudgetLimits());
1020
+ if (budget?.exceeded) {
1021
+ budget.usage = goal.runUsage;
1022
+ budget.weightedTokens = goal.runTokens;
1023
+ budget.cacheReadWeight = CACHE_READ_TOKEN_WEIGHT;
1024
+ }
1025
+ return budget;
1026
+ }
1027
+
1028
+ function budgetUsageDetail(budget) {
1029
+ if (!budget?.usage || budget.which !== 'tokens') return '';
1030
+ const u = budget.usage;
1031
+ const fmt = (n) => Number(n ?? 0).toLocaleString('en-US');
1032
+ const weight = Number(budget.cacheReadWeight ?? 0.1);
1033
+ return `budget-counted ${fmt(budget.weightedTokens)} tokens (cache read weighted ${weight}); raw input ${fmt(u.input_tokens)}, output ${fmt(u.output_tokens)}, cache write ${fmt(u.cache_creation_input_tokens)}, cache read ${fmt(u.cache_read_input_tokens)}`;
1034
+ }
1035
+
1036
+ function budgetReasonWithUsage(budget) {
1037
+ const detail = budgetUsageDetail(budget);
1038
+ return detail ? `${budget.reason}\n${detail}` : budget.reason;
1039
+ }
1040
+
1041
+ function refreshGoalHandoff(goal) {
1042
+ if (!goal) return null;
1043
+ const siblings = tasks.filter((t) => t.goalId === goal.id);
1044
+ goal.contextHandoff = buildContextHandoffSummary({ goal, tasks: siblings });
1045
+ goal.contextHandoffAt = new Date().toISOString();
1046
+ return goal.contextHandoff;
1047
+ }
1048
+
1049
+ function shouldFreshSessionForPlan(plan) {
1050
+ return ['compact', 'cold-handoff'].includes(plan?.sessionMode);
1051
+ }
1052
+
1053
+ function managerHandoffPrompt(goal, plan) {
1054
+ if (!shouldFreshSessionForPlan(plan)) return '';
1055
+ const mode = plan.sessionMode === 'compact' ? 'compact handoff' : 'fresh handoff';
1056
+ return [
1057
+ `(Manager session-hygiene: using ${mode} because ${plan.sessionReason}. Continue from this compact handoff and the files on disk; do not assume hidden chat history.)`,
1058
+ goal?.contextHandoff || refreshGoalHandoff(goal) || '',
1059
+ plan.workerPolicy?.reason ? `Worker policy: ${plan.workerPolicy.reason}` : '',
1060
+ latestFailurePolicy(goal)?.text || '',
1061
+ ].filter(Boolean).join('\n\n');
1062
+ }
1063
+
1064
+ function recentProjectFailureMemory(projectId, text) {
1065
+ const haystack = String(text ?? '').toLowerCase();
1066
+ return goals
1067
+ .filter((g) => g.projectId === projectId && Array.isArray(g.failureMemory) && g.failureMemory.length)
1068
+ .flatMap((g) => g.failureMemory.map((entry) => ({ goal: g, entry })))
1069
+ .filter(({ entry }) => {
1070
+ if (!haystack) return true;
1071
+ const blob = `${entry?.recurrenceKey ?? ''} ${entry?.rootCause ?? ''} ${entry?.whatHappened ?? ''}`.toLowerCase();
1072
+ return blob.includes('test') && /test|テスト|検証/.test(haystack)
1073
+ || blob.includes('budget') && /token|budget|予算|コンテキスト/.test(haystack)
1074
+ || blob.includes('proof') && /ui|proof|スクショ|画面/.test(haystack)
1075
+ || blob.includes('non-code') && /docs?|prd|調査|レポート|document/.test(haystack);
1076
+ })
1077
+ .sort((a, b) => String(b.entry.createdAt ?? '').localeCompare(String(a.entry.createdAt ?? '')))
1078
+ .slice(0, 3)
1079
+ .map(({ entry }) => entry);
1080
+ }
1081
+
1082
+ function recordGoalFailure(goal, { kind, reason = '', usage = null, changedFiles = [], testResult = null, attentionHadDetail = true } = {}) {
1083
+ if (!goal) return null;
1084
+ const siblings = tasks.filter((t) => t.goalId === goal.id);
1085
+ const entry = buildFailurePostmortem({
1086
+ goal,
1087
+ tasks: siblings,
1088
+ kind,
1089
+ reason,
1090
+ usage: usage ?? goal.runUsage,
1091
+ changedFiles,
1092
+ testResult: testResult ?? goal.testResult,
1093
+ attentionHadDetail,
1094
+ previousEntries: goal.failureMemory ?? [],
1095
+ });
1096
+ goal.failureMemory = appendFailureMemory(goal, entry);
1097
+ return entry;
1098
+ }
1099
+
1100
+ // Stop the goal cleanly on a run-budget breach: the offending task becomes
1101
+ // 'interrupted' (same status crash-recovery uses — retryable, never a silent
1102
+ // fail) and the goal becomes 'blocked' with a human-readable reason, exactly
1103
+ // like the existing verifyGate block states (goal.blocked = {kind, reason}) —
1104
+ // reusing the SAME Attention UI (reason + Retry + Archive) rather than adding
1105
+ // a new one.
1106
+ function queueBudgetContinuation(goal, task, budget) {
1107
+ const used = Number(goal.autoRework?.budgetStops ?? 0);
1108
+ if (used >= MAX_AUTO_BUDGET_REWORKS) return false;
1109
+ const now = new Date().toISOString();
1110
+ goal.autoRework = {
1111
+ ...(goal.autoRework ?? {}),
1112
+ budgetStops: used + 1,
1113
+ lastBudgetReason: budget.reason,
1114
+ lastBudgetAt: now,
1115
+ };
1116
+ goal.status = 'running';
1117
+ goal.blocked = null;
1118
+ goal.contextHandoff = buildContextHandoffSummary({ goal, tasks: tasks.filter((t) => t.goalId === goal.id) });
1119
+ goal.contextHandoffAt = now;
1120
+ goal.sessionId = null;
1121
+ goal.runElapsedMs = 0;
1122
+ goal.runTokens = 0;
1123
+ goal.runAttempts = 0;
1124
+ goal.runUsage = null;
1125
+ saveGoal(goal);
1126
+ const reply = queueReplyTask(goal, [
1127
+ `Manager budget-feedback: the previous worker run exceeded the token budget (${budgetReasonWithUsage(budget).replace(/\n/g, '; ')}).`,
1128
+ `Continue the same goal from the files already on disk${task?.title ? `, focused only on "${task.title}"` : ''}.`,
1129
+ 'Do not resume broad exploration or re-read the whole repository. Inspect only the directly relevant files and make the smallest change needed.',
1130
+ 'Run the narrowest relevant tests first; only run the full test suite after the small fix is in place.',
1131
+ 'If the remaining work is larger than one small patch, stop and report the next smallest step instead of expanding scope.',
1132
+ ].join('\n'));
1133
+ reply.budgetContinuation = true;
1134
+ saveTask(reply);
1135
+ return true;
1136
+ }
1137
+
1138
+ function blockForBudget(goal, task, budget) {
1139
+ task.status = 'interrupted';
1140
+ task.result = budgetReasonWithUsage(budget);
1141
+ task.finishedAt = new Date().toISOString();
1142
+ saveTask(task);
1143
+ recordGoalFailure(goal, { kind: 'budget', reason: budgetReasonWithUsage(budget), usage: goal?.runUsage });
1144
+ if (queueBudgetContinuation(goal, task, budget)) {
1145
+ task.status = 'skipped';
1146
+ task.result = `${budgetReasonWithUsage(budget)}\n\n自動縮小リトライに引き継ぎ済み`;
1147
+ saveTask(task);
1148
+ sendAct(task, `budget exceeded — queued a cold, narrow continuation`);
1149
+ return;
1150
+ }
1151
+ const retries = Number(goal.autoRework?.budgetStops ?? 0);
1152
+ const baseReason = budgetReasonWithUsage(budget);
1153
+ const reason = retries ? `${baseReason}\n自動縮小リトライ ${retries} 回後も停止` : baseReason;
1154
+ goal.status = 'blocked';
1155
+ goal.blocked = { kind: 'budget', reason };
1156
+ saveGoal(goal);
1157
+ sendAct(task, `stopped — ${reason}`);
1158
+ }
1159
+ // Pause (not fail) on a detected Claude usage/rate limit: mark the task
1160
+ // 'interrupted' (retryable) and the goal 'blocked' with kind 'rate-limit' +
1161
+ // resumeAt, then let rateLimitResumeSweep() (below) auto-resume it once the
1162
+ // cooldown elapses — no immediate retry, which would just re-hit the same
1163
+ // limit and burn the retry budget for nothing.
1164
+ function pauseForRateLimit(goal, task) {
1165
+ const attempt = (goal.rateLimitAttempts = (goal.rateLimitAttempts ?? 0) + 1);
1166
+ const delay = nextResumeDelay(attempt, rateLimitDelayOpts());
1167
+ const resumeAt = Date.now() + delay;
1168
+ goal.status = 'blocked';
1169
+ goal.blocked = { kind: 'rate-limit', reason: `paused: Claude usage limit — will resume automatically in ~${Math.max(1, Math.round(delay / 60000))}min`, resumeAt };
1170
+ recordGoalFailure(goal, { kind: 'rate-limit', reason: goal.blocked.reason, usage: goal?.runUsage });
1171
+ saveGoal(goal);
1172
+ task.status = 'interrupted';
1173
+ task.result = 'paused: Claude usage limit reached — will resume automatically';
1174
+ task.finishedAt = new Date().toISOString();
1175
+ saveTask(task);
1176
+ sendAct(task, `rate limit detected — pausing goal, auto-resume in ~${Math.max(1, Math.round(delay / 60000))}min`);
1177
+ }
1178
+ // Auto-resume: re-queue every 'interrupted' task the pause left behind and
1179
+ // flip the goal back to 'running'. Driven by rateLimitResumeSweep (a plain
1180
+ // setInterval) rather than a per-goal setTimeout so it survives a server
1181
+ // restart during the cooldown — resumeAt is persisted on goal.blocked, so the
1182
+ // very next sweep tick after boot picks up anything whose time has come.
1183
+ function resumeRateLimitedGoal(goal) {
1184
+ const stuck = tasks.filter((t) => t.goalId === goal.id && t.status === 'interrupted');
1185
+ for (const t of stuck) {
1186
+ t.status = 'queued'; t.result = null; t.finishedAt = null;
1187
+ saveTask(t);
1188
+ const q = queues.get(t.projectId);
1189
+ if (q && !q.waiting.includes(t)) q.waiting.push(t);
1190
+ }
1191
+ goal.status = 'running';
1192
+ goal.blocked = null;
1193
+ saveGoal(goal);
1194
+ if (stuck.length) pump(goal.projectId);
1195
+ }
1196
+ function rateLimitResumeSweep() {
1197
+ const now = Date.now();
1198
+ for (const g of goals) {
1199
+ if (g.status === 'blocked' && g.blocked?.kind === 'rate-limit' && g.blocked.resumeAt && g.blocked.resumeAt <= now) {
1200
+ resumeRateLimitedGoal(g);
1201
+ }
1202
+ }
1203
+ }
1204
+ const rateLimitSweepTimer = setInterval(rateLimitResumeSweep, RATE_LIMIT_SWEEP_MS);
1205
+ rateLimitSweepTimer.unref?.();
1206
+
1207
+ // Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
1208
+ // runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
1209
+ function queueReplyTask(goal, text) {
1210
+ const failurePolicy = latestFailurePolicy(goal);
1211
+ const promptText = failurePolicy ? `${failurePolicy.text}\n\n${text}` : text;
1212
+ goal.executionPlan = buildExecutionPlan({
1213
+ projectId: goal.projectId,
1214
+ text: promptText,
1215
+ usage: goal.runUsage,
1216
+ runTokens: goal.runTokens,
1217
+ runAttempts: goal.runAttempts,
1218
+ previousSession: Boolean(goal.sessionId || goal.contextHandoff),
1219
+ });
1220
+ if (shouldFreshSessionForPlan(goal.executionPlan)) refreshGoalHandoff(goal);
1221
+ saveGoal(goal);
1222
+ const nums = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
1223
+ const task = {
1224
+ id: nextId++, num: (nums.length ? Math.max(...nums) : 0) + 1,
1225
+ goalId: goal.id, projectId: goal.projectId, reply: true, model: goal.model, effort: goal.effort, agent: goal.agent,
1226
+ title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000),
1227
+ passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: '中',
1228
+ result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
1229
+ };
1230
+ tasks.push(task);
1231
+ saveTask(task);
1232
+ queues.get(goal.projectId).waiting.push(task);
1233
+ pump(goal.projectId);
1234
+ return task;
1235
+ }
1236
+
1237
+ function testOutputExcerpt(text) {
1238
+ const lines = String(text ?? '').split('\n').map((l) => l.trimEnd()).filter(Boolean);
1239
+ const interesting = lines.filter((l) =>
1240
+ /fail|error|assert|expected|actual|not ok|✖|テスト失敗|ERR!/i.test(l));
1241
+ const chosen = (interesting.length ? interesting : lines).slice(-80);
1242
+ return chosen.join('\n').slice(-3000);
1243
+ }
1244
+
1245
+ function workerPrompt(task, goal, lastFailure) {
1246
+ // Optional skill the user attached to this goal. Empirically (scratch worker,
1247
+ // haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
1248
+ // instruction as the FIRST line; a bare "/name" first line did not consistently
1249
+ // trigger the skill in `claude -p`. See commit body for the run evidence.
1250
+ const skill = task?.skill ?? goal?.skill;
1251
+ const agentName = workerAgent(task?.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code';
1252
+ return [
1253
+ skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
1254
+ `You are a ${agentName} worker managed by "Manager for AI". Do ONLY the task below inside this project directory.`,
1255
+ goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
1256
+ '',
1257
+ `TASK ${task.num}: ${task.title}`,
1258
+ task.detail ? `DETAIL: ${task.detail}` : '',
1259
+ goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
1260
+ task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
1261
+ lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
1262
+ goal ? `\n(全体ゴールの一部です: ${goal.text.slice(0, 500)})` : '',
1263
+ '',
1264
+ 'RULES:',
1265
+ '- Edit only files inside this directory. Keep changes minimal.',
1266
+ '- Keep the work bounded: inspect only files needed for this task; avoid broad repository scans unless they are necessary.',
1267
+ '- If the task is larger than one focused patch, make the smallest safe change and report the next step instead of expanding scope.',
1268
+ "- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the task.",
1269
+ // Token-frugality + the 10-min run cap: the worker used to run the WHOLE suite
1270
+ // (npm test) repeatedly, which the Manager ALSO runs at close-out — double work
1271
+ // that regularly overran the cap (→ a SIGTERM 'interrupted'). Write the test,
1272
+ // run only the RELEVANT file once, and leave the full-suite + verification to
1273
+ // the Manager. (Masa: worker が自前でテスト全回し→止まる、が最大の無駄。)',
1274
+ '- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
1275
+ '- 検証しすぎない: 自分の変更が動くと確認できたら、それ以上ログを漁ったり再検証を重ねたりせず、すぐに報告して終える(時間と token の無駄を避ける)。',
1276
+ '- Do not commit (git is handled by the Manager).',
1277
+ '- 最後に日本語で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。',
1278
+ ].filter(Boolean).join('\n');
1279
+ }
1280
+
1281
+ // A fresh ephemeral instance starts with zero history, but some pass
1282
+ // conditions are about completion-time behavior (e.g. task 46: a goal
1283
+ // created with the PR toggle off must finish without a PR). Actually
1284
+ // running a real worker task to produce that history would spawn a live
1285
+ // Claude Code worker back onto this very project directory (recursive,
1286
+ // slow, and liable to collide with the run already in progress) — so
1287
+ // instead seed two already-finished example goals directly into the
1288
+ // ephemeral instance's queue log before it boots: one that completed with
1289
+ // a PR, one that completed without. A verify check can then inspect
1290
+ // window.state.goals for real finished examples of both cases, per the
1291
+ // authorVerify instructions below.
1292
+ // 1x1 transparent GIF — just enough bytes for a real /proof/:id/proof.gif
1293
+ // response, so the PR-less review checklist's video slot (task 62) renders
1294
+ // an actual <img>, not the "no video" placeholder.
1295
+ const TINY_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', 'base64');
1296
+
1297
+ function seedEphemeralHistory(home, projectDir) {
1298
+ const dir = join(home, 'chat-runs');
1299
+ mkdirSync(dir, { recursive: true });
1300
+ writeFileSync(join(dir, 'tasks.jsonl'), buildEphemeralSeedLog());
1301
+ // Without this, the ephemeral instance has no projects.json of its own and
1302
+ // auto-generates exactly one project — not enough to verify the sidebar
1303
+ // can switch between projects (task 57).
1304
+ writeFileSync(join(home, 'projects.json'), JSON.stringify(buildEphemeralSeedProjects(projectDir), null, 2));
1305
+ // Task 62's 8 seeded review requirement tasks (ids 8-15, see
1306
+ // buildEphemeralSeedLines) each reference proof.gif — write the file each
1307
+ // one's proof dir actually serves so the checklist's <img> isn't a 404.
1308
+ for (let id = 8; id <= 15; id++) {
1309
+ const proofDir = join(dir, `proof-${id}`);
1310
+ mkdirSync(proofDir, { recursive: true });
1311
+ writeFileSync(join(proofDir, 'proof.gif'), TINY_GIF);
1312
+ }
1313
+ }
1314
+
1315
+ // When the verify target is a Manager app itself (our main dogfood case),
1316
+ // file:// or this server's own URL are both wrong: file:// cannot fetch
1317
+ // /api, and this server serves ITS code, not the project's edited code.
1318
+ // So boot a throwaway instance of the project's code and verify against it.
1319
+ // Ephemeral proof servers are spawned children of this process. If the parent
1320
+ // (prod) is restarted (every deploy does `kill <pid>`) while one is mid-capture,
1321
+ // it ORPHANS and keeps holding its port — this was the true source of the
1322
+ // session-long "server did not report ready in time" flakiness (leaked servers
1323
+ // squatting test/ephemeral ports). Track them and hard-kill on parent exit.
1324
+ const ephemeralChildren = new Set();
1325
+ let ephemeralReaperInstalled = false;
1326
+ function installEphemeralReaper() {
1327
+ if (ephemeralReaperInstalled) return;
1328
+ ephemeralReaperInstalled = true;
1329
+ const reap = () => { for (const c of ephemeralChildren) { try { c.kill('SIGKILL'); } catch { /* already gone */ } } };
1330
+ process.on('exit', reap);
1331
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { reap(); process.exit(0); });
1332
+ }
1333
+ async function startEphemeralApp(projectDir) {
1334
+ installEphemeralReaper();
1335
+ const port = 4460 + Math.floor(Math.random() * 30);
1336
+ const home = join(logDir, `ephemeral-${Date.now()}`);
1337
+ mkdirSync(home, { recursive: true });
1338
+ seedEphemeralHistory(home, projectDir);
1339
+ const child = spawn('node', [join(projectDir, 'engine', 'server.mjs')], {
1340
+ env: { ...process.env, MANAGER_PORT: String(port), MANAGER_HOME: home, MANAGER_OPEN_BROWSER: '0', MANAGER_SEED_RUNNING_FIXTURE: '1' },
1341
+ cwd: projectDir, stdio: 'ignore',
1342
+ });
1343
+ ephemeralChildren.add(child);
1344
+ child.on('close', () => ephemeralChildren.delete(child));
1345
+ for (let i = 0; i < 40; i++) {
1346
+ await new Promise((r) => setTimeout(r, 250));
1347
+ try {
1348
+ const key = readFileSync(join(home, 'secret.key'), 'utf8').trim();
1349
+ const r = await fetch(`http://localhost:${port}/api/state?key=${key}`);
1350
+ if (r.ok) return { url: `http://localhost:${port}/?key=${key}`, kill: () => { try { child.kill(); } catch {} } };
1351
+ } catch { /* not up yet */ }
1352
+ }
1353
+ try { child.kill(); } catch {}
1354
+ return null;
1355
+ }
1356
+
1357
+ function isManagerApp(goal, project) {
1358
+ if (!goal?.entry || /^https?:\/\//.test(goal.entry)) return false;
1359
+ const abs = resolve(join(project.dir, goal.entry));
1360
+ return abs === resolve(project.dir, 'app', 'index.html') && existsSync(join(project.dir, 'engine', 'server.mjs'));
1361
+ }
1362
+
1363
+ function entryUrlFor(goal, project) {
1364
+ return resolveEntryUrl({ entry: goal?.entry, projectDir: project.dir, managerRoot: ROOT, port: PORT, accessKey: ACCESS_KEY });
1365
+ }
1366
+
1367
+ function canSnapshot(project) {
1368
+ return existsSync(join(project.dir, 'engine', 'server.mjs')) && existsSync(join(project.dir, 'app', 'index.html'));
1369
+ }
1370
+
1371
+ function proofIntentFor(goal, task) {
1372
+ const text = `${goal?.text ?? ''}\n${goal?.plan?.join?.('\n') ?? ''}\n${task?.title ?? ''}\n${task?.detail ?? ''}`.toLowerCase();
1373
+ if (/retry|resume|view log|archive|失敗|エラー|blocked|interrupted|attention|to do|todo/.test(text)) {
1374
+ return 'attention-actions';
1375
+ }
1376
+ if (/review|approve|dismiss|proof|スクショ|画像|動画/.test(text)) return 'review';
1377
+ return 'general';
1378
+ }
1379
+
1380
+ async function verifyManagerProofTarget(page, ui, { goal = null, task = null } = {}) {
1381
+ const intent = proofIntentFor(goal, task);
1382
+ const root = await page.waitForSelector('#fsRoot', { timeout: 1500 }).catch(() => null);
1383
+ if (!root) {
1384
+ await exerciseUi(page, ui);
1385
+ return { pass: true, detail: 'UI change capture (auto, unverified)' };
1386
+ }
1387
+ await page.evaluate(() => {
1388
+ localStorage.setItem('layout', 'flagship');
1389
+ if (window.state?.projects?.some((p) => p.id === 'default')) window.state.active = 'default';
1390
+ window.render?.();
1391
+ });
1392
+ await ui.sleep(300);
1393
+
1394
+ if (intent === 'attention-actions') {
1395
+ await page.waitForSelector('#fsTodoSec .drow.err .acts2', { timeout: 5000 });
1396
+ await page.evaluate(() => document.querySelector('#fsTodoSec .drow.err')?.scrollIntoView({ block: 'center', inline: 'nearest' }));
1397
+ await ui.sleep(250);
1398
+ const metrics = await page.evaluate(() => {
1399
+ const rect = (el) => {
1400
+ const r = el?.getBoundingClientRect?.();
1401
+ return r ? { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, bottom: r.bottom, right: r.right, left: r.left } : null;
1402
+ };
1403
+ const root = document.querySelector('#fsRoot');
1404
+ const feed = document.querySelector('#fsFeedWrap') || document.querySelector('#fsFeed')?.parentElement;
1405
+ const lane = document.querySelector('#fsLane');
1406
+ const row = document.querySelector('#fsTodoSec .drow.err');
1407
+ const msg = row?.querySelector('.errline .msg');
1408
+ const acts = row?.querySelector('.acts2');
1409
+ const buttonText = [...(acts?.querySelectorAll('button') ?? [])].map((b) => b.textContent.trim()).join(' ');
1410
+ const rootR = rect(root), feedR = rect(feed), laneR = rect(lane), msgR = rect(msg), actsR = rect(acts);
1411
+ return {
1412
+ buttonText,
1413
+ hasRetry: /Retry|Resume/.test(buttonText),
1414
+ hasLog: /View log/.test(buttonText),
1415
+ hasArchive: /Archive/.test(buttonText),
1416
+ actionGap: msgR && actsR ? Math.round(actsR.top - msgR.bottom) : null,
1417
+ laneWidth: Math.round(laneR?.width ?? 0),
1418
+ feedWidth: Math.round(feedR?.width ?? 0),
1419
+ rootWidth: Math.round(rootR?.width ?? 0),
1420
+ laneVisible: Boolean(laneR && laneR.width >= 260 && laneR.right <= window.innerWidth + 2),
1421
+ };
1422
+ });
1423
+ const actionClose = metrics.actionGap == null || metrics.actionGap <= 18;
1424
+ const laneSane = metrics.laneVisible && metrics.feedWidth > 420 && metrics.laneWidth > 260;
1425
+ return {
1426
+ pass: Boolean(metrics.hasRetry && metrics.hasLog && actionClose && laneSane),
1427
+ detail: `Manager UI proof target: failed-row actions visible (${metrics.buttonText || 'none'}), action gap ${metrics.actionGap ?? 'n/a'}px, lane ${metrics.laneWidth}px/feed ${metrics.feedWidth}px`,
1428
+ };
1429
+ }
1430
+
1431
+ await exerciseUi(page, ui);
1432
+ const metrics = await page.evaluate(() => {
1433
+ const lane = document.querySelector('#fsLane')?.getBoundingClientRect();
1434
+ const feed = (document.querySelector('#fsFeedWrap') || document.querySelector('#fsFeed')?.parentElement)?.getBoundingClientRect();
1435
+ return { laneWidth: Math.round(lane?.width ?? 0), feedWidth: Math.round(feed?.width ?? 0) };
1436
+ });
1437
+ return { pass: true, detail: `Manager UI capture (${intent}); lane ${metrics.laneWidth}px/feed ${metrics.feedWidth}px` };
1438
+ }
1439
+
1440
+ // Zero-LLM screenshot + short clip of an ephemeral instance of the project's
1441
+ // current code, stored as the task's proof. Used automatically for UI tasks
1442
+ // without an explicit pass condition, and on demand via
1443
+ // POST /api/tasks/:id/snapshot to backfill old "No capture yet" reviews.
1444
+ //
1445
+ // The clip is no longer an idle page (a 0-second clip of nothing moving said
1446
+ // nothing a screenshot didn't) — exerciseUi() moves the cursor and scrolls
1447
+ // the live app during the capture, so the GIF/mp4 actually SHOW the change
1448
+ // instead of a static landing shot. The `detail` text is deliberately NOT
1449
+ // "snapshot"-worded: app/index.html's proofMediaHtml() keys its PNG-only /
1450
+ // no-video display off /snapshot|スナップショット/i in proof.detail (that
1451
+ // heuristic exists for the old idle capture) — this capture has real motion
1452
+ // worth showing, so it earns the same GIF+tap-to-play-mp4 treatment as a
1453
+ // verified run.
1454
+ //
1455
+ // opts.baselineDir: when given and it's a genuinely different checkout than
1456
+ // `project.dir` (see shouldCaptureBaseline — never fabricate a before/after
1457
+ // from the same directory), best-effort capture a BASELINE screenshot too
1458
+ // (single attempt, screenshot-only, never throws) so the review card can
1459
+ // show before/after. Reviewer UI already supports this via
1460
+ // proof.beforePng/beforeGif (beforeAfterMediaHtml in app/index.html).
1461
+ async function captureSnapshotProof(task, project, opts = {}) {
1462
+ const eph = await startEphemeralApp(project.dir);
1463
+ if (!eph) throw new Error('ephemeral instance failed to boot');
1464
+ try {
1465
+ const proofDir = join(logDir, `proof-${task.id}`);
1466
+ mkdirSync(proofDir, { recursive: true });
1467
+ const managerApp = canSnapshot(project);
1468
+ const snap = await runVerification({
1469
+ entryUrl: eph.url,
1470
+ verify: async (page, ui) => managerApp
1471
+ ? verifyManagerProofTarget(page, ui, { goal: opts.goal, task })
1472
+ : (await exerciseUi(page, ui), { pass: true, detail: 'UI change capture (auto, unverified)' }),
1473
+ outBase: join(proofDir, 'snapshot'),
1474
+ viewport: managerApp ? { width: 1440, height: 900, deviceScaleFactor: 2 } : undefined,
1475
+ });
1476
+ task.proof = {
1477
+ pass: snap.pass, detail: snap.detail || 'UI change capture (auto, unverified)',
1478
+ gif: snap.gifPath?.split('/').pop() ?? null,
1479
+ png: snap.shotPath?.split('/').pop() ?? null,
1480
+ mp4: snap.videoPath?.split('/').pop() ?? null,
1481
+ };
1482
+ const baselineDir = opts.baselineDir;
1483
+ if (shouldCaptureBaseline({ baselineDir, afterDir: project.dir, canCaptureBaseline: baselineDir ? canSnapshot({ dir: baselineDir }) : false })) {
1484
+ let beph = null;
1485
+ try {
1486
+ beph = await startEphemeralApp(baselineDir);
1487
+ if (beph) {
1488
+ const bsnap = await runVerification({
1489
+ entryUrl: beph.url,
1490
+ verify: async (page, ui) => managerApp
1491
+ ? verifyManagerProofTarget(page, ui, { goal: opts.goal, task })
1492
+ : (await exerciseUi(page, ui), { pass: true, detail: 'baseline (before)' }),
1493
+ outBase: join(proofDir, 'before'),
1494
+ viewport: managerApp ? { width: 1440, height: 900, deviceScaleFactor: 2 } : undefined,
1495
+ record: false, // comparison shot only — not worth a second video encode
1496
+ });
1497
+ task.proof.beforePng = bsnap.shotPath?.split('/').pop() ?? null;
1498
+ }
1499
+ } catch (e) {
1500
+ // Before/after is a nice-to-have on top of the mandatory "after"
1501
+ // capture above (already saved) — never let a flaky baseline boot
1502
+ // fail the whole capture. Report it and move on.
1503
+ sendAct(task, `proof: baseline (before) capture skipped — ${String(e.message ?? e).slice(0, 100)}`);
1504
+ } finally { beph?.kill(); }
1505
+ }
1506
+ saveTask(task);
1507
+ return task;
1508
+ } finally { eph.kill(); }
1509
+ }
1510
+
1511
+ async function runTask(task) {
1512
+ const project = projects.find((p) => p.id === task.projectId);
1513
+ const goal = goals.find((g) => g.id === task.goalId);
1514
+ task.status = 'running'; task.startedAt = new Date().toISOString();
1515
+ if (goal && !goal.startedAt && !task.reply) { goal.startedAt = task.startedAt; saveGoal(goal); }
1516
+ saveTask(task);
1517
+ sendProcessAct(task, `Working on "${task.title}"${task.reply ? ' as a follow-up' : ''}.`);
1518
+
1519
+ // Everything the worker touches happens in the goal's own worktree (clean
1520
+ // base from origin/main, isolated from other goals and the user's real dir).
1521
+ const workDir = goal ? goalWorkDir(goal, project) : project.dir;
1522
+ const wproject = { ...project, dir: workDir };
1523
+ sendProcessAct(task, `Prepared isolated worktree: ${workDir}`);
1524
+ const before = await gitChanges(workDir);
1525
+ sendProcessAct(task, `Checked starting git state (${before.length} changed file${before.length === 1 ? '' : 's'} before this task).`);
1526
+ // Plan mode makes no edits, so there is nothing to verify — never author a
1527
+ // check or boot an ephemeral instance for it.
1528
+ const planMode = goal?.mode === 'plan' && goal?.planPhase !== 'executing';
1529
+ const permissionMode = permissionModeFor(planMode ? 'plan' : (task.mode ?? goal?.mode));
1530
+ const entryUrl = (task.passCondition && !planMode) ? entryUrlFor(goal, wproject) : null;
1531
+ const maxAttempts = entryUrl ? 3 : 1;
1532
+ const t0 = Date.now();
1533
+
1534
+ // Thread replies resume the goal's Claude Code session — same context,
1535
+ // same conversation, no re-briefing (and no wasted tokens).
1536
+ if (task.reply) {
1537
+ const plan = goal?.executionPlan;
1538
+ const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
1539
+ const prompt = [
1540
+ managerHandoffPrompt(goal, plan),
1541
+ task.detail,
1542
+ '',
1543
+ '(User follow-up on the goal you worked on. Act on it inside this project directory; reply briefly in Japanese.)',
1544
+ ].filter(Boolean).join('\n');
1545
+ const callT0 = Date.now();
1546
+ sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker with the existing goal context.`);
1547
+ const r = await runWorkerAgent({
1548
+ agent: task.agent ?? goal?.agent,
1549
+ prompt, cwd: workDir, tools: WORKER_TOOLS, resume: resumeSession,
1550
+ // A reply that revises a plan-review must stay plan mode (no edits); a
1551
+ // reply/rework on a normal goal stays acceptEdits as before.
1552
+ permissionMode: permissionModeFor(goal?.mode === 'plan' && goal?.planPhase !== 'executing' ? 'plan' : undefined),
1553
+ model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
1554
+ onTodos: (todos) => sendTodos(task, todos),
1555
+ onChild: (c) => trackGoalWorker(goal?.id, c),
1556
+ });
1557
+ if (goal?.cancelled) return; // goal deleted mid-run → don't resurrect the task / run the gate
1558
+ // goal 427 §1/§2: a reply/rework is still a real worker spawn — count it
1559
+ // against the goal's run budget, and treat a rate-limit hit here the same
1560
+ // as in the main loop (pause + auto-resume, not a normal failure).
1561
+ if (goal && isRateLimited(r.result)) { pauseForRateLimit(goal, task); return; }
1562
+ const replyBudget = trackGoalRunUsage(goal, Date.now() - callT0, r.usage);
1563
+ if (replyBudget?.exceeded) { blockForBudget(goal, task, replyBudget); return; }
1564
+ sendProcessAct(task, `Worker finished with exit code ${r.code}.`);
1565
+ task.secs = Math.round((Date.now() - t0) / 1000);
1566
+ task.status = r.code === 0 ? 'done' : 'failed';
1567
+ task.result = r.result.slice(0, 4000);
1568
+ task.usage = sumUsage([r.usage]);
1569
+ if (r.sessionId && goal) { goal.sessionId = r.sessionId; saveGoal(goal); }
1570
+ const afterR = await gitChanges(workDir);
1571
+ task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
1572
+ sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
1573
+ task.finishedAt = new Date().toISOString();
1574
+ saveTask(task);
1575
+ // A rework (comment/dismiss) must re-enter the verify gate — re-test,
1576
+ // re-capture proof, update the SAME PR (openPR existingBranch) — and land
1577
+ // back in Review. Without this the reworked goal is stuck 'running' forever
1578
+ // (dismissGoal set it running) and never returns to the reviewer.
1579
+ //
1580
+ // NOT awaited (Masa dogfood #111: "doingの内容が終わって、to doが始まるまで
1581
+ // にラグがある"): this task's own work is already done and saved above —
1582
+ // finishGoalIfComplete's tests/PR/summarize/proof pipeline can take a long
1583
+ // time and must not hold this task's running-slot hostage. It runs in the
1584
+ // goal's own isolated worktree, so it's safe to let it proceed while
1585
+ // pump() (below, via runTask's caller) immediately frees the slot and
1586
+ // starts the next queued task, possibly from a different goal.
1587
+ if (goal) {
1588
+ sendProcessAct(task, 'Starting goal close-out: tests, proof gate, review summary, and PR update if needed.');
1589
+ finishGoalIfComplete(goal, wproject, task).catch((e) => sendAct(task, `goal close: ${String(e.message ?? e).slice(0, 120)}`));
1590
+ }
1591
+ else startNextStacked(task.projectId);
1592
+ return;
1593
+ }
1594
+
1595
+ let verify = null, verdict = null, lastFailure = null, code = 0, result = '', usages = [], timedOut = false;
1596
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1597
+ let callT0 = Date.now();
1598
+ const plan = goal?.executionPlan;
1599
+ const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
1600
+ const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure)].filter(Boolean).join('\n\n');
1601
+ sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
1602
+ let r = await runWorkerAgent({
1603
+ agent: task.agent ?? goal?.agent,
1604
+ prompt, cwd: workDir, tools: WORKER_TOOLS,
1605
+ resume: resumeSession, permissionMode,
1606
+ model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
1607
+ onTodos: (todos) => sendTodos(task, todos),
1608
+ onChild: (c) => trackGoalWorker(goal?.id, c),
1609
+ });
1610
+ if (goal?.cancelled) return; // deleted mid-run → stop, don't run verify/PR
1611
+ // goal 427 §2: a detected usage/rate limit is NOT a normal failure — pause
1612
+ // and auto-resume instead of falling into the cold-retry/failure paths
1613
+ // below, which would just re-hit the same limit.
1614
+ if (goal && isRateLimited(r.result)) { pauseForRateLimit(goal, task); return; }
1615
+ // goal 427 §1: count this spawn against the goal's run budget regardless
1616
+ // of exit code — a budget breach stops the goal even if THIS call itself
1617
+ // "succeeded".
1618
+ let budget = trackGoalRunUsage(goal, Date.now() - callT0, r.usage);
1619
+ if (budget?.exceeded) { blockForBudget(goal, task, budget); return; }
1620
+ if (r.code !== 0 && goal?.sessionId) {
1621
+ // The warm session can expire or be cleaned up between tasks; one cold
1622
+ // retry distinguishes "resume broke" from "the task itself failed".
1623
+ sendAct(task, 'warm session unavailable — retrying with a fresh session…');
1624
+ goal.sessionId = null; saveGoal(goal);
1625
+ callT0 = Date.now();
1626
+ sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
1627
+ r = await runWorkerAgent({
1628
+ agent: task.agent ?? goal?.agent,
1629
+ prompt: workerPrompt(task, goal, lastFailure), cwd: workDir, tools: WORKER_TOOLS,
1630
+ permissionMode,
1631
+ model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
1632
+ onTodos: (todos) => sendTodos(task, todos),
1633
+ onChild: (c) => trackGoalWorker(goal?.id, c),
1634
+ });
1635
+ if (goal?.cancelled) return;
1636
+ if (goal && isRateLimited(r.result)) { pauseForRateLimit(goal, task); return; }
1637
+ budget = trackGoalRunUsage(goal, Date.now() - callT0, r.usage);
1638
+ if (budget?.exceeded) { blockForBudget(goal, task, budget); return; }
1639
+ }
1640
+ ({ code, result } = r);
1641
+ timedOut = Boolean(r.timedOut);
1642
+ usages.push(r.usage);
1643
+ sendProcessAct(task, timedOut
1644
+ ? `Worker attempt ${attempt} was stopped at the ${Math.round(WORKER_TIMEOUT_MS / 60000)}-minute run limit (exit ${code}).`
1645
+ : `Worker attempt ${attempt} finished with exit code ${code}.`);
1646
+ if (r.sessionId && goal) { goal.sessionId = r.sessionId; saveGoal(goal); }
1647
+ if (!entryUrl) break;
1648
+ try {
1649
+ if (!verify) {
1650
+ sendAct(task, 'verify: authoring the check…');
1651
+ verify = await authorVerify(task, goal, wproject);
1652
+ }
1653
+ sendAct(task, `verify: running independent check (attempt ${attempt}/${maxAttempts})…`);
1654
+ const proofDir = join(logDir, `proof-${task.id}`);
1655
+ mkdirSync(proofDir, { recursive: true });
1656
+ let eph = null, target = entryUrl;
1657
+ if (isManagerApp(goal, wproject)) {
1658
+ sendAct(task, 'verify: booting a fresh instance of the edited app…');
1659
+ eph = await startEphemeralApp(workDir);
1660
+ if (eph) target = eph.url;
1661
+ }
1662
+ try {
1663
+ verdict = await runVerification({ entryUrl: target, verify, outBase: join(proofDir, `attempt-${attempt}`) });
1664
+ } finally {
1665
+ eph?.kill();
1666
+ }
1667
+ sendAct(task, `verify: ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
1668
+ if (verdict.pass) break;
1669
+ lastFailure = verdict.detail;
1670
+ } catch (e) {
1671
+ sendAct(task, `verify: could not run — ${String(e.message ?? e).slice(0, 120)}`);
1672
+ verdict = null;
1673
+ break;
1674
+ }
1675
+ }
1676
+
1677
+ if (goal?.cancelled) return; // deleted during the verify loop → don't resurrect the task
1678
+ task.secs = Math.round((Date.now() - t0) / 1000);
1679
+ // Status decision, most-authoritative first:
1680
+ // 1. an independent verify PASS = verified success — wins even if the worker
1681
+ // was force-stopped afterwards (the change is proven good).
1682
+ // 2. a forced STOP (10-min timeout / SIGTERM, exit 143) = 'interrupted', NOT
1683
+ // 'failed' — it's a retryable halt, not "the worker's code was wrong". A
1684
+ // FAIL verdict on a killed (incomplete) worker isn't authoritative either,
1685
+ // so the stop reason takes precedence. (Mirrors the budget-breach path.)
1686
+ // 3. a verify that actually ran and FAILED = 'failed'.
1687
+ // 4. no verify = the exit code decides.
1688
+ const forcedStop = isForcedStop(code, timedOut);
1689
+ task.status = verdict?.pass ? 'done'
1690
+ : forcedStop ? 'interrupted'
1691
+ : verdict ? 'failed'
1692
+ : (code === 0 ? 'done' : 'failed');
1693
+ // Never leave the reason as a bare "(no output)": if the worker produced no
1694
+ // report text, result already carries workerExitReason(); make doubly sure a
1695
+ // force-stopped task states WHY so the Attention card explains itself.
1696
+ if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
1697
+ task.result = result.slice(0, 4000);
1698
+ task.usage = sumUsage(usages);
1699
+ if (verdict) {
1700
+ task.result = `検証${verdict.pass ? 'PASS' : 'FAIL'}: ${verdict.detail}\n\n${task.result}`.slice(0, 4000);
1701
+ task.proof = {
1702
+ pass: verdict.pass,
1703
+ detail: verdict.detail.slice(0, 500),
1704
+ gif: verdict.gifPath?.split('/').pop() ?? null,
1705
+ png: verdict.shotPath?.split('/').pop() ?? null,
1706
+ mp4: verdict.videoPath?.split('/').pop() ?? null,
1707
+ // STRONG proof: this came from an independent passCondition verification,
1708
+ // not a generic auto-snapshot. Only this flag lets a force-stopped (interrupted)
1709
+ // task count toward goal completion (isProofVerified) — a weak "UI capture"
1710
+ // snapshot never auto-promotes an interrupted goal past human review.
1711
+ verified: true,
1712
+ };
1713
+ }
1714
+ const after = await gitChanges(workDir);
1715
+ task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
1716
+ sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
1717
+
1718
+ // A UI change with no explicit pass condition used to finish with no
1719
+ // evidence at all ("UI変更のはずなのに写真が撮られてない"). Capture a cheap
1720
+ // zero-LLM snapshot (screenshot + short clip) from an ephemeral instance
1721
+ // of the edited app so the review card always has something to look at.
1722
+ // baselineDir = the user's real project dir (unmodified, pre-goal code) —
1723
+ // when it's genuinely different from wproject.dir (a real worktree, not
1724
+ // the shared-dir fallback), also attempt a before/after pair.
1725
+ if (!verdict && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
1726
+ try {
1727
+ sendAct(task, 'proof: capturing UI change (screenshot + clip) of the edited app…');
1728
+ await captureSnapshotProof(task, wproject, { baselineDir: project.dir, goal });
1729
+ sendProcessAct(task, `Proof capture finished: ${task.proof?.pass === false ? 'target check failed' : 'screenshot/clip attached'}.`);
1730
+ if (task.proof && task.proof.pass === false) {
1731
+ task.status = 'failed';
1732
+ task.result = `proof FAIL: ${task.proof.detail}\n\n${task.result ?? ''}`.slice(0, 4000);
1733
+ }
1734
+ } catch (e) { sendAct(task, `proof: capture failed — ${String(e.message ?? e).slice(0, 100)}`); }
1735
+ }
1736
+ task.finishedAt = new Date().toISOString();
1737
+ saveTask(task);
1738
+ sendProcessAct(task, `Task saved as ${task.status}.`);
1739
+
1740
+ // goal bookkeeping + optional PR when the whole goal is finished.
1741
+ //
1742
+ // NOT awaited — same reasoning as the reply branch above: this is the line
1743
+ // that was holding this task's running-slot hostage for the duration of
1744
+ // finishGoalIfComplete's tests/PR/summarize/proof pipeline (runProjectTests
1745
+ // alone allows up to 240s), so a task already sitting queued in To Do — for
1746
+ // this goal's next sibling or a different goal entirely — waited behind it
1747
+ // instead of starting the moment THIS task's own work finished (saved just
1748
+ // above). runTask's caller (pump(), defined earlier in this file) frees
1749
+ // this task's slot and starts the next queued task(s) as soon as this
1750
+ // function's promise settles, so letting that happen now (rather than
1751
+ // after goal-closing) is the fix for the "doing → next todo" lag.
1752
+ // finishGoalIfComplete still runs to completion in the goal's own
1753
+ // isolated worktree, and still calls startNextStacked() for any legitimately
1754
+ // stacked goal when it's done.
1755
+ if (goal) {
1756
+ sendProcessAct(task, 'Starting goal close-out: tests, proof gate, review summary, and PR update if needed.');
1757
+ finishGoalIfComplete(goal, wproject, task).catch((e) => sendAct(task, `goal close: ${String(e.message ?? e).slice(0, 120)}`));
1758
+ }
1759
+ }
1760
+
1761
+ // Manager-run tests (never trust the worker's "all green" claim): run the
1762
+ // project's own `npm test` in the goal's worktree and parse node --test's
1763
+ // "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
1764
+ function runTestsOnce(dir) {
1765
+ return new Promise((res) => {
1766
+ execFile('npm', ['test'], { cwd: dir, timeout: 240000, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
1767
+ const text = `${out}\n${err}`;
1768
+ const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
1769
+ const passed = n(/ℹ pass (\d+)/) ?? n(/# pass (\d+)/) ?? n(/(\d+) passing/) ?? 0;
1770
+ const failedTotal = n(/ℹ fail (\d+)/) ?? n(/# fail (\d+)/) ?? n(/(\d+) failing/) ?? (e ? 1 : 0);
1771
+ // Don't block on INFRA flakes: the integration suite spawns real servers,
1772
+ // and under load (prod + several worker goals running) a server can miss
1773
+ // its startup window ("did not report ready in time"). That's not a code
1774
+ // regression. BUT fail CLOSED: only discount those timeouts when NO real
1775
+ // assertion failed. A genuine AssertionError sharing a run with a timeout
1776
+ // must still block — never let arithmetic subtraction mask a real failure.
1777
+ const timeoutFlakes = (text.match(/did not report ready in time/g) || []).length;
1778
+ const realAssertion = /AssertionError|Expected values to be|expected .+ (?:to |but )/i.test(text);
1779
+ const failed = realAssertion ? failedTotal : Math.max(0, failedTotal - timeoutFlakes);
1780
+ res({ passed, failed, detail: testOutputExcerpt(text) });
1781
+ });
1782
+ });
1783
+ }
1784
+ async function runProjectTests(dir) {
1785
+ let hasTest = false;
1786
+ try { hasTest = !!JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts?.test; } catch { /* no package.json */ }
1787
+ if (!hasTest) return { ran: false };
1788
+ let r = await runTestsOnce(dir);
1789
+ // Flake resistance: the integration suite spawns real servers; under load a
1790
+ // startup can time out. Re-run once and take the fewer failures — a real
1791
+ // failure fails both runs, a flake clears on the retry — so a good goal is
1792
+ // never blocked on a flaky test.
1793
+ if (r.failed > 0) {
1794
+ const r2 = await runTestsOnce(dir);
1795
+ r = {
1796
+ passed: Math.max(r.passed, r2.passed),
1797
+ failed: Math.min(r.failed, r2.failed),
1798
+ detail: (r2.failed <= r.failed ? r2.detail : r.detail) || r.detail || r2.detail || '',
1799
+ };
1800
+ }
1801
+ return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '' };
1802
+ }
1803
+
1804
+ // ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
1805
+ // Plain Dismiss → automatic retest ×3: re-run the goal's deterministic
1806
+ // verification (the project's own `npm test` in the goal's worktree — reuses
1807
+ // the same pipeline as verifyGate's runTestsOnce, NO LLM) three times.
1808
+ // 3/3 green → back to 'review' with the note; a reproduced failure → back to
1809
+ // To Do as reworking with the evidence attached to the worker respawn.
1810
+ // Async by design: progress and the settled outcome stream over SSE via
1811
+ // saveGoal, and the intent survives a restart because status 'retesting' is
1812
+ // persisted in tasks.jsonl like every other status (re-kicked on boot below).
1813
+ const RETEST_RUNS = 3;
1814
+ // One live token per goal: a cancelled (or superseded) job compares its own
1815
+ // token before EVERY write, so an UNDO mid-run can never be overwritten by a
1816
+ // stale job finishing later.
1817
+ const retestTokens = new Map();
1818
+ async function runRetestJob(goal, token) {
1819
+ const live = () => retestTokens.get(goal.id) === token
1820
+ && goals.some((g) => g.id === goal.id) && goal.status === 'retesting';
1821
+ const project = projects.find((p) => p.id === goal.projectId);
1822
+ if (!project) return;
1823
+ const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
1824
+ let hasTest = false;
1825
+ try { hasTest = !!JSON.parse(readFileSync(join(wdir, 'package.json'), 'utf8')).scripts?.test; } catch { /* no package.json */ }
1826
+ const results = [];
1827
+ if (!hasTest) {
1828
+ results.push({ ran: false });
1829
+ } else {
1830
+ for (let i = 0; i < RETEST_RUNS; i++) {
1831
+ if (!live()) return; // cancelled (UNDO) / superseded / deleted → discard
1832
+ const r = await runTestsOnce(wdir);
1833
+ if (!live()) return; // cancelled while this run was in flight
1834
+ results.push({ ran: true, passed: r.passed, failed: r.failed });
1835
+ goal.retest = { state: 'running', runs: results.length, total: RETEST_RUNS, startedAt: goal.retest?.startedAt };
1836
+ saveGoal(goal); // SSE progress tick
1837
+ if (r.failed > 0) break; // a reproduced failure decides the outcome
1838
+ }
1839
+ }
1840
+ if (!live()) return;
1841
+ const outcome = computeRetestOutcome(results, RETEST_RUNS);
1842
+ goal.retest = {
1843
+ state: 'done', verdict: outcome.verdict, passed: outcome.verdict === 'pass',
1844
+ note: outcome.note, runs: results.length, total: RETEST_RUNS, at: new Date().toISOString(),
1845
+ };
1846
+ goal.status = outcome.status;
1847
+ saveGoal(goal);
1848
+ if (outcome.status === 'running') {
1849
+ queueReplyTask(goal, `差し戻し(自動再テストで問題が再現): ${outcome.evidence}。修正してください。`);
1850
+ }
1851
+ }
1852
+
1853
+ // Revert (ゴミ箱): the small wrapper around the destructive gh/git calls, so
1854
+ // the decision logic (revertGoal/planRevertActions in lib.mjs) stays pure and
1855
+ // testable without network. Worktree discipline as engine/pr.mjs: only the
1856
+ // goal's own .manager-wt worktree is removed — the user's working tree is
1857
+ // never touched (closing a PR / deleting a REMOTE branch is API-side only).
1858
+ function execRevertAction(action, goal, project) {
1859
+ return new Promise((resolvePromise, reject) => {
1860
+ if (action.kind === 'close-pr') {
1861
+ const args = ['pr', 'close', action.url, ...(action.deleteBranch ? ['--delete-branch'] : [])];
1862
+ execFile('gh', args, { cwd: project.dir }, (e, out, err) => {
1863
+ if (e) return reject(new Error(`gh pr close: ${(err || e.message || '').trim().slice(0, 300)}`));
1864
+ resolvePromise();
1865
+ });
1866
+ return;
1867
+ }
1868
+ if (action.kind === 'delete-remote-branch') {
1869
+ execFile('git', ['-C', project.dir, 'push', 'origin', '--delete', action.branch], (e, out, err) => {
1870
+ // a branch that was never pushed is already "never happened" — not an error
1871
+ if (e && !/remote ref does not exist/i.test(String(err))) {
1872
+ return reject(new Error(`git push --delete: ${(err || e.message || '').trim().slice(0, 300)}`));
1873
+ }
1874
+ resolvePromise();
1875
+ });
1876
+ return;
1877
+ }
1878
+ if (action.kind === 'remove-worktree') { removeGoalWorkDir(goal, project); return resolvePromise(); }
1879
+ resolvePromise();
1880
+ });
1881
+ }
1882
+
1883
+ // Ledger Details data: diff stats (+a −d), changed files and the diff text
1884
+ // for a goal's branch vs the repo default branch — plain git, no LLM,
1885
+ // cached per branch tip so repeated opens are free until the goal updates.
1886
+ const goalDiffCache = new Map();
1887
+ function gitOut(dir, args) {
1888
+ const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
1889
+ return r.status === 0 ? r.stdout : null;
1890
+ }
1891
+ function computeGoalDiff(goal, project) {
1892
+ let repoRoot;
1893
+ try { repoRoot = gitSync(project.dir, ['rev-parse', '--show-toplevel']); }
1894
+ catch { return null; } // not a git repo — no diff to show
1895
+ const branch = goal.prBranch ?? `manager-ai/goal-${goal.id}`;
1896
+ const head = gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', branch])?.trim()
1897
+ || gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${branch}`])?.trim();
1898
+ if (!head) return null; // goal never grew a branch (e.g. no-code goal)
1899
+ const cached = goalDiffCache.get(goal.id);
1900
+ if (cached && cached.head === head) return cached.payload;
1901
+ const base = repoDefaultBranch(project.dir);
1902
+ const baseRef = gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${base}`]) ? `origin/${base}` : base;
1903
+ const stats = parseNumstat(gitOut(repoRoot, ['diff', '--numstat', `${baseRef}...${head}`]) ?? '');
1904
+ const { text, truncated } = truncateDiffText(gitOut(repoRoot, ['diff', `${baseRef}...${head}`]) ?? '', 400);
1905
+ const payload = { branch, base: baseRef, head, add: stats.add, del: stats.del, files: stats.files, diff: text, truncated };
1906
+ goalDiffCache.set(goal.id, { head, payload });
1907
+ return payload;
1908
+ }
1909
+ // One cheap LLM pass turns the verbose worker output + diff into a two-line,
1910
+ // plain-language review headline in the user's language: what to CHECK and what
1911
+ // CHANGED. Never trusts the worker to self-format.
1912
+ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
1913
+ const lang = reviewDefinition.language || 'ja';
1914
+ const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
1915
+ const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
1916
+ const fallback = { check: '', changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80) };
1917
+ if (!changed.length && !reports) return fallback;
1918
+ const prompt = [
1919
+ `あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で2行だけ書いてください。`,
1920
+ '専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
1921
+ '{"check":"何を確認すれば良いか(1行)","changed":"何を変えたか(1行)"}',
1922
+ '',
1923
+ `ゴール: ${(goal.text ?? '').slice(0, 300)}`,
1924
+ `変更ファイル: ${changed.join(', ') || '(なし)'}`,
1925
+ `ワーカー報告:\n${reports.slice(0, 1500)}`,
1926
+ ].join('\n');
1927
+ try {
1928
+ const r = await runClaude({ prompt, cwd: dir, tools: 'Read', model: 'haiku' });
1929
+ const m = r.result.match(/\{[\s\S]*\}/);
1930
+ if (m) { const o = JSON.parse(m[0]); return { check: String(o.check ?? '').replace(/\s+/g, ' ').slice(0, 120), changed: String(o.changed ?? '').replace(/\s+/g, ' ').slice(0, 120) || fallback.changed }; }
1931
+ } catch { /* fall through */ }
1932
+ return fallback;
1933
+ }
1934
+
1935
+ // ---- merge-time conflict detection -----------------------------------------
1936
+ // Deterministic, no LLM (mission: "conflict 判断はしてほしい"). Each goal runs
1937
+ // in its own worktree, so parallel goals never corrupt each other's WORKING
1938
+ // files — but if two goals edited the same repo path, their branches will
1939
+ // still conflict when they land. Rather than guess ahead of time (a goal's
1940
+ // real changed files aren't known until its worker has actually run),
1941
+ // detection is best-effort and runs at goal-finish time: compare the
1942
+ // just-finished goal's files against every sibling in the same project that
1943
+ // is still running or sitting in Review with an unmerged PR — whatever THAT
1944
+ // sibling has changed so far (its own conflicts get finalized in turn when
1945
+ // it finishes). Flags both sides so a human reviewing either one sees
1946
+ // "touches the same files as #N — review/merge in order". We deliberately do
1947
+ // NOT auto-serialize the conflicting pair here (see mission notes): the
1948
+ // worker for the second goal has typically already run by the time this is
1949
+ // known, and holding a finished PR back adds a new class of "why is my done
1950
+ // work stuck" bug for a UI that doesn't exist yet to explain it — so this is
1951
+ // flag-only, left for the (future) UI/human to act on.
1952
+ const SCRATCH_FILE_RE = /(^|\/)\.tmp|\.log$|(^|\/)\.manager-wt(\/|$)|(^|\/)node_modules(\/|$)|(^|\/)secret\.key$/;
1953
+ function goalChangedFileUnion(goalId) {
1954
+ return [...new Set(tasks.filter((t) => t.goalId === goalId).flatMap((t) => t.changedFiles ?? []))]
1955
+ .filter((f) => f && !SCRATCH_FILE_RE.test(f));
1956
+ }
1957
+ const CONFLICT_CANDIDATE_STATUSES = ['running', 'review', 'blocked', 'partial', 'retesting'];
1958
+ function updateGoalConflicts(goal) {
1959
+ const files = goalChangedFileUnion(goal.id);
1960
+ const candidates = goals
1961
+ .filter((g) => g.id !== goal.id && g.projectId === goal.projectId && CONFLICT_CANDIDATE_STATUSES.includes(g.status))
1962
+ .map((g) => ({ id: g.id, changedFiles: goalChangedFileUnion(g.id) }));
1963
+ const conflicts = detectConflicts({ id: goal.id, changedFiles: files }, candidates);
1964
+ goal.conflictsWith = conflicts;
1965
+ // Reciprocal: a sibling flagged from this side may not know about it yet
1966
+ // (e.g. it finished earlier, before this goal's files existed) — stamp it
1967
+ // too so its own Review card also surfaces the overlap.
1968
+ for (const otherId of conflicts) {
1969
+ const other = goals.find((g) => g.id === otherId);
1970
+ if (other && !(other.conflictsWith ?? []).includes(goal.id)) {
1971
+ other.conflictsWith = [...(other.conflictsWith ?? []), goal.id];
1972
+ saveGoal(other);
1973
+ }
1974
+ }
1975
+ return conflicts;
1976
+ }
1977
+
1978
+ // Shared by task completion, thread replies and the skip endpoint, so every
1979
+ // path that closes a task also closes its goal and wakes the queue.
1980
+ async function finishGoalIfComplete(goal, project, activityTask = null) {
1981
+ const goalAct = (line) => { if (activityTask) sendProcessAct(activityTask, line); };
1982
+ if (!goal || !project) return;
1983
+ const siblings = tasks.filter((t) => t.goalId === goal.id);
1984
+ if (!siblings.length || !siblings.every((t) => ['done', 'failed', 'interrupted', 'skipped'].includes(t.status))) return;
1985
+ if (!['running', 'partial'].includes(goal.status)) { startNextStacked(goal.projectId); return; }
1986
+ goalAct(`Close-out started for goal-${goal.id}: ${siblings.length} task${siblings.length === 1 ? '' : 's'} ready for final checks.`);
1987
+ // Plan mode: the worker produced a PLAN and made no edits. Land it in Review
1988
+ // as a plan-review (no PR, no proof gate — nothing changed) with the plan
1989
+ // body captured, so the reviewer sees "PLAN — approve to execute". Approving
1990
+ // it (server approve handler) re-queues the goal to actually execute.
1991
+ if (goal.mode === 'plan' && goal.planPhase !== 'executing') {
1992
+ goal.planText = siblings
1993
+ .filter((t) => t.status === 'done' && t.result)
1994
+ .map((t) => t.result).join('\n\n').slice(0, 8000) || '(no plan text produced)';
1995
+ goal.planPhase = 'awaiting-approval';
1996
+ goal.summary = buildGoalSummary({ goalText: goal.text, tasks: siblings });
1997
+ goal.blocked = null;
1998
+ goal.status = 'review';
1999
+ saveGoal(goal);
2000
+ startNextStacked(goal.projectId);
2001
+ return;
2002
+ }
2003
+ const judged = siblings.filter((t) => t.status !== 'skipped');
2004
+ const allDone = siblings.every(taskCountsAsComplete);
2005
+ const allVerified = judged.length > 0 && judged.every((t) => t.proof?.pass === true);
2006
+ const reviewDefinition = getReviewDefinition(goal.projectId);
2007
+ // Always PR from the goal's own worktree (has only this goal's changes on top
2008
+ // of current main). Resolve it here too, so callers that pass the real project
2009
+ // (e.g. the skip endpoint) still PR from the isolated worktree, not the shared dir.
2010
+ const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
2011
+ const prProject = { ...project, dir: wdir };
2012
+ // Masa's model: any goal that TOUCHED CODE opens a PR (reviewed via the PR);
2013
+ // a no-code goal is a report/confirmation. So always attempt a PR — createGoalPR
2014
+ // no-ops when there are no changed files — instead of gating on goal.wantsPR
2015
+ // (which defaulted to false, so almost nothing ever became a PR). Always call
2016
+ // it (not just `if (!goal.pr)`): when a goal already has a PR — e.g. a review
2017
+ // comment sent it back for rework and the reply task just finished — this is
2018
+ // the only place that pushes the rework diff, and createGoalPR itself decides
2019
+ // "new PR" vs "push onto the existing PR's branch" from goal.pr/goal.prBranch.
2020
+ // Conflict check runs BEFORE the PR call and doesn't depend on it (no gh/
2021
+ // network needed) — this goal's changed files are already final at this
2022
+ // point (all its tasks are done), so its conflictsWith is accurate even if
2023
+ // PR creation itself fails (e.g. no `gh` auth) or is still no-op'd (no code).
2024
+ updateGoalConflicts(goal);
2025
+ goalAct('Checking changed files and preparing the PR branch if code changed.');
2026
+ await createGoalPR(goal, prProject, siblings, reviewDefinition, activityTask);
2027
+ // goal 427 §4: classify the change's risk from the SAME changed-files/diff
2028
+ // that just went into (or would have gone into) the PR, and attach it to
2029
+ // the goal so the review card can surface it. Never an LLM call — pure
2030
+ // string/regex matching (classifyChangeRisk in lib.mjs).
2031
+ goal.risk = computeGoalRisk(goal, prProject, siblings);
2032
+ goal.summary = buildGoalSummary({ goalText: goal.text, tasks: siblings });
2033
+ await verifyGate(goal, prProject, siblings, project, activityTask);
2034
+ // A high-risk change (secrets/auth/payment/db-migration/destructive) must
2035
+ // never slip past a human — verifyGate's nextGoalStatus never returns
2036
+ // 'done' directly today (only an explicit Approve or an actual PR merge
2037
+ // does), so this is a safety net against that ever changing, not a fix for
2038
+ // an observed bypass: force 'review' (never silently stay 'partial' or
2039
+ // otherwise skip the human gate) unless the goal is already the even more
2040
+ // conservative 'blocked' (tests failing / proof missing — still human-gated).
2041
+ if (goal.risk?.shouldPause && !['blocked', 'running'].includes(goal.status)) goal.status = 'review';
2042
+ if (['review', 'done', 'blocked', 'archived'].includes(goal.status)) {
2043
+ refreshGoalHandoff(goal);
2044
+ if (goal.sessionId) {
2045
+ goal.sessionId = null;
2046
+ goal.sessionClosedAt = new Date().toISOString();
2047
+ goal.executionPlan = {
2048
+ ...(goal.executionPlan ?? {}),
2049
+ optimizationTechniques: [...new Set([...(goal.executionPlan?.optimizationTechniques ?? []), 'session close', 'AI handoff'])],
2050
+ };
2051
+ }
2052
+ }
2053
+ saveGoal(goal);
2054
+ goalAct(`Close-out finished: goal is now ${goal.status}.`);
2055
+ startNextStacked(goal.projectId);
2056
+ }
2057
+
2058
+ // goal 427 §4: pure classification (classifyChangeRisk) fed with THIS goal's
2059
+ // real changed files + real diff. Best-effort on the diff (computeGoalDiff
2060
+ // returns null for a no-code goal, or if the branch/git lookup fails for any
2061
+ // reason) — a missing diff still classifies correctly off filenames alone,
2062
+ // it just can't catch a diffPattern-only rule (e.g. an added `rm -rf` on an
2063
+ // otherwise unremarkable file).
2064
+ function computeGoalRisk(goal, project, siblings) {
2065
+ const files = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].filter((f) => f && !SCRATCH_FILE_RE.test(f));
2066
+ let diffText = '';
2067
+ try { diffText = computeGoalDiff(goal, project)?.diff ?? ''; } catch { /* best effort — filename-only classification still runs */ }
2068
+ const r = classifyChangeRisk(files, diffText);
2069
+ return { level: r.level, shouldPause: r.shouldPause, categories: r.categories, sensitiveFiles: r.sensitiveFiles };
2070
+ }
2071
+
2072
+ // The Manager-verified review gate (shared by goal completion and /reverify):
2073
+ // run OUR OWN tests, generate a plain-language headline, and require proof for a
2074
+ // UI change — capturing it (3 tries) if missing. Any failure → 'blocked' (shown
2075
+ // in Attention with the reason); otherwise → review/done via nextGoalStatus.
2076
+ // Nothing reaches Review that a human can't judge.
2077
+ async function verifyGate(goal, prProject, siblings, baseProject, activityTask = null) {
2078
+ const goalAct = (line) => { if (activityTask) sendProcessAct(activityTask, line); };
2079
+ const wdir = prProject.dir;
2080
+ const reviewDefinition = getReviewDefinition(goal.projectId);
2081
+ const judged = siblings.filter((t) => t.status !== 'skipped');
2082
+ const allDone = siblings.every(taskCountsAsComplete);
2083
+ const allVerified = judged.length > 0 && judged.every((t) => t.proof?.pass === true);
2084
+ const changedFiles = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].filter((f) => f && !SCRATCH_FILE_RE.test(f));
2085
+ if (hasTestRelevantChanges(changedFiles)) {
2086
+ goalAct(`Running project tests in the goal worktree (${changedFiles.length} changed file${changedFiles.length === 1 ? '' : 's'}).`);
2087
+ goal.testResult = await runProjectTests(wdir);
2088
+ goalAct(`Project tests finished: ${goal.testResult.failed ?? 0} failed, ${goal.testResult.passed ?? 0} passed.`);
2089
+ } else {
2090
+ goal.testResult = { ran: false, skipped: true, skippedReason: changedFiles.length ? 'documentation-only change' : 'no changed files' };
2091
+ goalAct(`Skipped project tests: ${goal.testResult.skippedReason}.`);
2092
+ }
2093
+ goalAct('Writing the review summary from the worker report and diff.');
2094
+ goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2095
+ const uiTasks = siblings.filter((t) => isUiChange(t.changedFiles));
2096
+ // Only require/capture proof for projects we can actually boot & screenshot
2097
+ // (Manager-shaped). A generic web repo that edits a stylesheet can't be
2098
+ // captured — it must still reach Review as a report, not be trapped in
2099
+ // 'blocked' forever (PRD §4A/§6#1: block only what a human can't judge, not
2100
+ // what the tool can't photograph).
2101
+ const canCapture = canSnapshot(prProject);
2102
+ if (shouldCaptureProof({ changedFiles: uiTasks.flatMap((t) => t.changedFiles ?? []), hasProof: uiTasks.some((t) => t.proof), canCapture })) {
2103
+ const t = uiTasks[uiTasks.length - 1];
2104
+ goalAct('UI changed and no proof is attached yet, so Manager is capturing proof now.');
2105
+ for (let i = 0; i < 3 && !t.proof; i++) {
2106
+ try {
2107
+ goalAct(`Proof capture attempt ${i + 1}/3.`);
2108
+ await captureSnapshotProof(t, prProject, { baselineDir: baseProject?.dir, goal });
2109
+ } catch (e) {
2110
+ goalAct(`Proof capture attempt ${i + 1}/3 failed: ${String(e.message ?? e).slice(0, 140)}`);
2111
+ }
2112
+ }
2113
+ saveTask(t);
2114
+ }
2115
+ const proofMissing = canCapture && uiTasks.length > 0 && !uiTasks.some((t) => t.proof);
2116
+ const proofFailing = canCapture && uiTasks.some((t) => t.proof && t.proof.pass === false);
2117
+ const testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
2118
+ // §4補足: requireVerifyPass (opt-in, off by default) makes the gate strict —
2119
+ // EVERY task must carry a passing proof, not just UI tasks. Only ever tightens;
2120
+ // never conflicts with §6 (still can't reach Done without a human).
2121
+ const verifyFail = reviewDefinition.requireVerifyPass && !allVerified;
2122
+ if (testsFailing) {
2123
+ const failure = recordGoalFailure(goal, {
2124
+ kind: 'test',
2125
+ reason: `テスト失敗 ${goal.testResult.failed} 件`,
2126
+ changedFiles,
2127
+ testResult: goal.testResult,
2128
+ });
2129
+ const used = Number(goal.autoRework?.testFailures ?? 0);
2130
+ if (used < MAX_AUTO_TEST_REWORKS) {
2131
+ goal.autoRework = {
2132
+ ...(goal.autoRework ?? {}),
2133
+ testFailures: used + 1,
2134
+ lastReason: `テスト失敗 ${goal.testResult.failed} 件`,
2135
+ lastAt: new Date().toISOString(),
2136
+ };
2137
+ goal.blocked = null;
2138
+ goal.status = 'running';
2139
+ saveGoal(goal);
2140
+ goalAct(`Tests failed; queued automatic worker feedback (${used + 1}/${MAX_AUTO_TEST_REWORKS}) with the failing output.`);
2141
+ queueReplyTask(goal, [
2142
+ `Manager auto-feedback: npm test failed (${goal.testResult.failed} failing).`,
2143
+ failure?.whatHappened ? `What happened: ${failure.whatHappened}` : '',
2144
+ failure?.yourCall ?? 'Please inspect the failure output below, fix the regression, and rerun the project tests until they pass.',
2145
+ failure?.nextPolicy ? `Prevention policy: ${failure.nextPolicy}` : '',
2146
+ 'Keep the existing goal/PR branch; do not start a new project or unrelated refactor.',
2147
+ '',
2148
+ 'Failing test output:',
2149
+ goal.testResult.detail || '(no detailed output captured)',
2150
+ ].filter(Boolean).join('\n').slice(0, 4000));
2151
+ return;
2152
+ }
2153
+ }
2154
+ if (testsFailing || proofMissing || proofFailing || verifyFail) {
2155
+ if (!testsFailing) {
2156
+ recordGoalFailure(goal, {
2157
+ kind: (proofMissing || proofFailing) ? 'proof' : 'verify',
2158
+ reason: proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
2159
+ : proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
2160
+ : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2161
+ changedFiles,
2162
+ testResult: goal.testResult,
2163
+ });
2164
+ }
2165
+ goal.blocked = {
2166
+ kind: testsFailing ? 'test' : (proofMissing || proofFailing) ? 'proof' : 'verify',
2167
+ reason: testsFailing ? `テスト失敗 ${goal.testResult.failed} 件(自動修正 ${Number(goal.autoRework?.testFailures ?? 0)} 回後も失敗)`
2168
+ : proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
2169
+ : proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
2170
+ : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2171
+ };
2172
+ goal.status = 'blocked';
2173
+ goalAct(`Goal blocked: ${goal.blocked.reason}`);
2174
+ } else {
2175
+ goal.blocked = null;
2176
+ if (goal.autoRework?.testFailures) goal.autoRework = { ...goal.autoRework, resolvedAt: new Date().toISOString() };
2177
+ goal.status = nextGoalStatus({ allDone, wantsPR: goal.wantsPR, allVerified, reviewDefinition });
2178
+ goalAct(`Verification gate passed; moving goal to ${goal.status}.`);
2179
+ }
2180
+ }
2181
+
2182
+ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT_REVIEW_DEFINITION, activityTask = null) {
2183
+ const goalAct = (line) => { if (activityTask) sendProcessAct(activityTask, line); };
2184
+ // Keep the PR to real source: drop worker scratch/debug leftovers (.tmp_* files
2185
+ // and dirs, *.log, ephemeral diag homes, worktree/deps) that otherwise pollute
2186
+ // the diff. Proof artifacts (.manager-proof) are intentional and kept.
2187
+ const SCRATCH = /(^|\/)\.tmp|\.log$|(^|\/)\.manager-wt(\/|$)|(^|\/)node_modules(\/|$)|(^|\/)secret\.key$/;
2188
+ const files = [...new Set(goalTasks.flatMap((t) => t.changedFiles))].filter((f) => f && !SCRATCH.test(f));
2189
+ // No code changed → nothing to open a PR for. This is a confirmation /
2190
+ // investigation goal, not a failure: it still lands in Review (nextGoalStatus),
2191
+ // where the worker's report IS the "please confirm" summary. So clear any PR
2192
+ // error and return — don't scare the reviewer with a red prError. (A rework
2193
+ // round can't actually hit this — its files are a superset of the ones that
2194
+ // already got it a PR — but the guard is harmless either way.)
2195
+ if (!files.length) { goal.pr = null; goal.prError = null; saveGoal(goal); goalAct('No code changes detected, so PR creation is skipped.'); return; }
2196
+ // Rework: the goal already has an open PR from an earlier finish. Push onto
2197
+ // that same branch instead of opening a second PR for the goal (see openPR's
2198
+ // existingBranch/existingPrUrl mode in pr.mjs).
2199
+ const isRework = Boolean(goal.pr && goal.prBranch);
2200
+ try {
2201
+ goalAct(`${isRework ? 'Updating the existing PR' : 'Opening a PR'} with ${files.length} changed file${files.length === 1 ? '' : 's'}.`);
2202
+ // proof artifacts for verified tasks: committed to the branch, GIF auto-plays
2203
+ const proofRel = `.manager-proof/goal-${goal.id}`;
2204
+ const proofItems = [];
2205
+ const extraCopies = [];
2206
+ for (const t of goalTasks) {
2207
+ if (!t.proof?.pass) continue;
2208
+ const dir = join(logDir, `proof-${t.id}`);
2209
+ const item = { num: t.num, title: t.title, passCondition: t.passCondition, detail: t.proof.detail, gifName: null, shotName: null, videoName: null, beforeShotName: null };
2210
+ if (t.proof.gif) { item.gifName = `t${t.num}.gif`; extraCopies.push({ src: join(dir, t.proof.gif), destRel: `${proofRel}/t${t.num}.gif` }); }
2211
+ if (t.proof.png) { item.shotName = `t${t.num}.png`; extraCopies.push({ src: join(dir, t.proof.png), destRel: `${proofRel}/t${t.num}.png` }); }
2212
+ if (t.proof.mp4) { item.videoName = `t${t.num}.mp4`; extraCopies.push({ src: join(dir, t.proof.mp4), destRel: `${proofRel}/t${t.num}.mp4` }); }
2213
+ if (t.proof.beforePng) { item.beforeShotName = `t${t.num}-before.png`; extraCopies.push({ src: join(dir, t.proof.beforePng), destRel: `${proofRel}/t${t.num}-before.png` }); }
2214
+ proofItems.push(item);
2215
+ }
2216
+ const ownerRes = spawnSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { cwd: project.dir, encoding: 'utf8' });
2217
+ const owner = ownerRes.status === 0 ? ownerRes.stdout.trim() : null;
2218
+ // PR body in the USER's language (reviewDefinition.language, default ja) —
2219
+ // not forced English/bilingual (Masa: 「ユーザーの言語で」).
2220
+ const lang = reviewDefinition.language || 'ja';
2221
+ const L = (en, ja) => (lang === 'en' ? en : ja);
2222
+ const bodyFor = (branch) => [
2223
+ ...(proofItems.length && owner ? [
2224
+ L('## Proof — one tap, plays in place', '## 動作確認 — 1タップでその場で再生'),
2225
+ '',
2226
+ `https://github.com/${owner}/blob/${branch}/${proofRel}/PROOF.md`,
2227
+ '',
2228
+ ] : []),
2229
+ L('## Goal', '## ゴール'),
2230
+ '',
2231
+ goal.text.slice(0, 1500),
2232
+ '',
2233
+ ...buildReviewRuleSection(reviewDefinition.description),
2234
+ 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.${proofItems.length ? ' Tasks with a pass condition were verified independently in a headless browser.' : ''}`,
2235
+ `Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItems.length ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
2236
+ '',
2237
+ L('## Tasks and worker reports', '## タスクとworker報告'),
2238
+ ...goalTasks.map((t) => `\n### ${t.num} ${t.title} — ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 800)}`),
2239
+ '',
2240
+ L(`Changed files: ${files.map((f) => `\`${f}\``).join(' ')}`, `変更ファイル: ${files.map((f) => `\`${f}\``).join(' ')}`),
2241
+ ].join('\n');
2242
+ const title = `Manager for AI: ${isRework ? L('rework — ', '再作業 — ') : ''}${goal.plan?.[0] ?? goal.text.slice(0, 60)}${goal.plan?.length > 1 ? L(` +${goal.plan.length - 1} more`, ` 他${goal.plan.length - 1}件`) : ''}`;
2243
+ const { url, branch } = openPR({
2244
+ repoDir: project.dir, slug: `goal-${goal.id}`,
2245
+ title,
2246
+ body: 'placeholder',
2247
+ buildBody: bodyFor,
2248
+ files,
2249
+ extraCopies,
2250
+ extraWrites: proofItems.length ? [{
2251
+ destRel: `${proofRel}/PROOF.md`,
2252
+ content: buildGoalProofMd({ goalText: goal.text, items: proofItems }),
2253
+ }] : [],
2254
+ ...(isRework ? { existingBranch: goal.prBranch, existingPrUrl: goal.pr } : {}),
2255
+ });
2256
+ goal.pr = url;
2257
+ goal.prBranch = branch;
2258
+ goal.prError = null;
2259
+ goalAct(`PR ready: ${url}`);
2260
+ } catch (e) {
2261
+ // A rework push failing shouldn't blow away an already-working PR url —
2262
+ // only clear it on a from-scratch creation failure.
2263
+ if (!isRework) goal.pr = null;
2264
+ goal.prError = String(e.message ?? e).slice(0, 300);
2265
+ goalAct(`PR step failed: ${goal.prError}`);
2266
+ }
2267
+ saveGoal(goal);
2268
+ }
2269
+
2270
+ // ---- http ----------------------------------------------------------------
2271
+ function json(res, code, body) {
2272
+ res.writeHead(code, { 'content-type': 'application/json' });
2273
+ res.end(JSON.stringify(body));
2274
+ }
2275
+
2276
+ const server = createServer(async (req, res) => {
2277
+ const url = new URL(req.url, `http://localhost:${PORT}`);
2278
+
2279
+ // auth, in order: (1) a verified Cloudflare Access (Google) identity —
2280
+ // authed as that email, no ?key= needed; (2) the existing ?key=/cookie gate
2281
+ // (dev, localhost, backward compat); (3) else 403. req.authEmail is exposed
2282
+ // to handlers below for future per-user work (no per-user isolation yet).
2283
+ // /oauth/callback is the loopback landing for "Sign in with Google" — the
2284
+ // billing Worker 302s the browser here with a signed license token and no
2285
+ // ?key= (it can't know the local key), so it must bypass the key gate. It's
2286
+ // safe: it only accepts a Worker-signed token (verified before use) plus a
2287
+ // one-time nonce the engine itself issued, and it grants no app access — it
2288
+ // only records entitlement identity.
2289
+ const authExempt = url.pathname === '/oauth/callback';
2290
+ req.authEmail = authExempt ? null : await authFromCfAccess(req);
2291
+ if (!req.authEmail && !authExempt) {
2292
+ const cookieKey = /(?:^|;\s*)mkey=([^;]+)/.exec(req.headers.cookie ?? '')?.[1];
2293
+ // `|| cookieKey` (not `??`): an EMPTY ?key= (e.g. a proof <img> URL built
2294
+ // with an empty AKEY) is a non-nullish '' that would otherwise shadow a
2295
+ // valid mkey cookie and 403 the request. Treat empty-string as "no key".
2296
+ const givenKey = url.searchParams.get('key') || cookieKey;
2297
+ if (givenKey !== ACCESS_KEY) {
2298
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
2299
+ return res.end('403 — open this app with ?key=<access key> (see engine/secret.key on the host)');
2300
+ }
2301
+ if (url.searchParams.get('key')) {
2302
+ res.setHeader('set-cookie', `mkey=${ACCESS_KEY}; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`);
2303
+ }
2304
+ }
2305
+
2306
+ if (url.pathname === '/' || url.pathname === '/index.html') {
2307
+ // no-cache: browsers were keeping a STALE index.html (no cache header before),
2308
+ // so deployed fixes/UI didn't appear until a hard-refresh — the root cause of
2309
+ // recurring "still old / still slow / まだ日本語" reports. Always revalidate.
2310
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store, no-cache, must-revalidate', pragma: 'no-cache', expires: '0' });
2311
+ // Inject the access key into the page so the client can always self-authenticate
2312
+ // /api/* calls, even on a Cloudflare-Access-gated URL with no ?key= (CF Access
2313
+ // gates the whole hostname; when the browser's fetch()/EventSource lacks a valid
2314
+ // CF session, CF 302s cross-origin to its login page, which fetch() can't follow
2315
+ // — the API call just fails). This response only reaches clients that already
2316
+ // passed the auth check above (CF Access identity, or key/cookie), so this isn't
2317
+ // a new exposure — it's the same key that was already usable via ?key= in the URL.
2318
+ const html = readFileSync(join(ROOT, 'app', 'index.html'), 'utf8')
2319
+ .replace('<head>', `<head>\n<script>window.__MGR_KEY=${JSON.stringify(ACCESS_KEY)};</script>`);
2320
+ return res.end(html);
2321
+ }
2322
+ // Design tokens + theme (owned by the design/CDO role). Served as a real file
2323
+ // so the two roles never edit the same file: theme.css (look) vs index.html (impl).
2324
+ if (url.pathname === '/theme.css') {
2325
+ res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-cache' });
2326
+ return res.end(readFileSync(join(ROOT, 'app', 'theme.css')));
2327
+ }
2328
+ // brand-font exploration faces (@font-face data URIs, latin subsets) — big & static, cache hard.
2329
+ if (url.pathname === '/fonts.css') {
2330
+ res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'public, max-age=86400' });
2331
+ return res.end(readFileSync(join(ROOT, 'app', 'fonts.css')));
2332
+ }
2333
+ // design wallpapers for the image-backed themes (theme 6 composer band; later 3/4).
2334
+ // Whitelisted filename → served from app/wp/. Static design assets, cacheable.
2335
+ const wpMatch = url.pathname.match(/^\/wp\/([\w-]+\.jpg)$/);
2336
+ if (wpMatch) {
2337
+ const file = join(ROOT, 'app', 'wp', wpMatch[1]);
2338
+ if (!existsSync(file)) { res.writeHead(404); return res.end('not found'); }
2339
+ res.writeHead(200, { 'content-type': 'image/jpeg', 'cache-control': 'public, max-age=86400' });
2340
+ return res.end(readFileSync(file));
2341
+ }
2342
+
2343
+ if (url.pathname === '/api/state') {
2344
+ await syncAllExternal();
2345
+ await syncGoalMerges();
2346
+ const mcpPath = join(ROOT, 'engine', 'mcp.mjs');
2347
+ const connectCommand = `claude mcp remove -s user manager-for-ai 2>/dev/null; claude mcp add -s user manager-for-ai -e MANAGER_BASE=http://localhost:${PORT} -e MANAGER_KEY=${ACCESS_KEY} -- node ${mcpPath}`;
2348
+ // Stage 2 (multi-user): the owner sees every goal (including legacy
2349
+ // goals with no `owner` field); a normal Google user sees only goals
2350
+ // stamped with their own identity. Tasks/queueOrder follow their goal's
2351
+ // visibility so a filtered-out goal never leaks through its tasks.
2352
+ // Projects/columns/review-definitions stay shared for everyone.
2353
+ const identity = identityFor(req);
2354
+ const visibleGoals = goals.filter((g) => goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL));
2355
+ const visibleGoalIds = new Set(visibleGoals.map((g) => g.id));
2356
+ const visibleTasks = tasks.filter((t) => t.goalId == null || visibleGoalIds.has(t.goalId));
2357
+ const visibleTaskIds = new Set(visibleTasks.map((t) => t.id));
2358
+ const queueOrder = Object.fromEntries(projects.map((p) => [p.id, queues.get(p.id).waiting.map((t) => t.id).filter((id) => visibleTaskIds.has(id))]));
2359
+ const externalActivity = projects.flatMap((p) =>
2360
+ (externalSync[p.id]?.commits ?? []).map((c) => ({ projectId: p.id, ...c })));
2361
+ const projectColumns = Object.fromEntries(projects.map((p) => [p.id, getColumns(p.id)]));
2362
+ const projectReviewDefs = Object.fromEntries(projects.map((p) => [p.id, getReviewDefinition(p.id)]));
2363
+ // Per-project running count (out of PROJECT_MAX_PARALLEL) + the caps
2364
+ // themselves, so a later UI can show "N running in parallel" without
2365
+ // recomputing it client-side.
2366
+ const runningCounts = Object.fromEntries(projects.map((p) => [p.id, queues.get(p.id).running.size]));
2367
+ const parallelLimits = { project: PROJECT_MAX_PARALLEL, global: GLOBAL_MAX_PARALLEL };
2368
+ // Billing snapshot for the UI: identity (signed-in email), plan (from the
2369
+ // token's isPaying), current free-tier usage vs limits, and the Stripe
2370
+ // price. usage counts this identity's goals — the same numbers the gate
2371
+ // enforces — so the settings PLAN card can't drift from the actual wall.
2372
+ const freeUsage = computeFreeTierUsage(identity);
2373
+ const billing = {
2374
+ licensed: Boolean(licenseState.email),
2375
+ email: licenseState.email,
2376
+ isPaying: Boolean(licenseState.isPaying),
2377
+ plan: licenseState.isPaying ? 'paid' : 'free',
2378
+ usage: { pending: freeUsage.pendingCount, cumulative: freeUsage.cumulativeCount },
2379
+ limits: { pending: FREE_TIER_LIMITS.maxPendingGoals, cumulative: FREE_TIER_LIMITS.maxCumulativeGoals },
2380
+ price: await getBillingPrice(),
2381
+ siteUrl: BILLING_API_URL || null,
2382
+ };
2383
+ return json(res, 200, { projects, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents: WORKER_AGENTS, connectCommand, queueOrder, externalActivity, workflowColumns: projectColumns, reviewDefinitions: projectReviewDefs, runningCounts, parallelLimits, uiVersion: uiVersion(), billing });
2384
+ }
2385
+
2386
+ // Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
2387
+ // copies from galda.app after paying. GET reports whether one is on file
2388
+ // (for the settings panel); POST verifies+persists a newly pasted token.
2389
+ if (url.pathname === '/api/license' && req.method === 'GET') {
2390
+ return json(res, 200, { licensed: Boolean(licenseState.email), email: licenseState.email });
2391
+ }
2392
+ if (url.pathname === '/api/license' && req.method === 'POST') {
2393
+ let body = '';
2394
+ req.on('data', (d) => { body += d; });
2395
+ req.on('end', async () => {
2396
+ let input;
2397
+ try { input = JSON.parse(body || '{}'); } catch { return json(res, 400, { error: 'bad json' }); }
2398
+ const result = await activateLicense(input?.token);
2399
+ if (!result.ok) return json(res, 400, { error: result.error });
2400
+ return json(res, 200, { licensed: true, email: result.email });
2401
+ });
2402
+ return;
2403
+ }
2404
+
2405
+ // GET /api/signin-url — mints a one-time nonce and returns the billing
2406
+ // Worker's /connect URL for "Sign in with Google". The app opens this in the
2407
+ // browser; Google bounces back to /oauth/callback (below) with a token.
2408
+ if (url.pathname === '/api/signin-url' && req.method === 'GET') {
2409
+ if (!BILLING_API_URL) return json(res, 501, { error: 'sign-in not configured (MANAGER_BILLING_API_URL unset)' });
2410
+ const nonce = newSigninNonce();
2411
+ const signinUrl = `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(nonce)}`;
2412
+ return json(res, 200, { url: signinUrl });
2413
+ }
2414
+
2415
+ // GET /oauth/callback?token&email&state | ?error&state — loopback landing for
2416
+ // the Google sign-in (auth-exempt above). Validates the nonce, activates the
2417
+ // signed token, then renders a page that hands the result back to the app
2418
+ // window (postMessage) and closes itself.
2419
+ if (url.pathname === '/oauth/callback' && req.method === 'GET') {
2420
+ const state = url.searchParams.get('state') ?? '';
2421
+ const oauthError = url.searchParams.get('error');
2422
+ const token = url.searchParams.get('token');
2423
+ const nonceOk = consumeSigninNonce(state);
2424
+ let outcome;
2425
+ if (!nonceOk) outcome = { ok: false, error: 'sign-in expired or was not started from this app' };
2426
+ else if (oauthError) outcome = { ok: false, error: oauthError };
2427
+ else {
2428
+ const result = await activateLicense(token);
2429
+ outcome = result.ok ? { ok: true, email: result.email } : { ok: false, error: result.error };
2430
+ }
2431
+ res.writeHead(outcome.ok ? 200 : 400, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
2432
+ return res.end(signinResultPage(outcome));
2433
+ }
2434
+
2435
+ // POST /api/checkout — start a Stripe Checkout (Upgrade). Proxied to
2436
+ // billing-api so the browser stays same-origin; the paying email is the
2437
+ // signed-in one when known (else Stripe collects it). Returns { url }.
2438
+ if (url.pathname === '/api/checkout' && req.method === 'POST') {
2439
+ let body = '';
2440
+ req.on('data', (d) => { body += d; });
2441
+ req.on('end', async () => {
2442
+ let input; try { input = JSON.parse(body || '{}'); } catch { return json(res, 400, { error: 'bad json' }); }
2443
+ const r = await billingPost('/checkout', { email: licenseState.email || undefined, successUrl: input?.successUrl, cancelUrl: input?.cancelUrl });
2444
+ if (r.ok && r.url) return json(res, 200, { url: r.url });
2445
+ return json(res, r.status || 502, { error: r.error || 'could not start checkout' });
2446
+ });
2447
+ return;
2448
+ }
2449
+
2450
+ // POST /api/portal — open the Stripe Billing Portal for the signed-in paying
2451
+ // user (manage/cancel). Requires a verified license email. Returns { url }.
2452
+ if (url.pathname === '/api/portal' && req.method === 'POST') {
2453
+ if (!licenseState.email) return json(res, 400, { error: 'sign in first' });
2454
+ const r = await billingPost('/portal', { email: licenseState.email });
2455
+ if (r.ok && r.url) return json(res, 200, { url: r.url });
2456
+ return json(res, r.status || 502, { error: r.error || 'could not open billing portal' });
2457
+ }
2458
+
2459
+ // POST /api/refresh-billing — re-check isPaying live (used right after a
2460
+ // checkout return: the webhook that flips the KV record can lag a beat).
2461
+ // Updates the display cache so /api/state shows Paid without a re-sign-in;
2462
+ // the license token on disk is unchanged (isPaying there is just the boot seed).
2463
+ if (url.pathname === '/api/refresh-billing' && req.method === 'POST') {
2464
+ if (!licenseState.email) return json(res, 200, { licensed: false, isPaying: false });
2465
+ const fresh = await fetchIsPaying(licenseState.email);
2466
+ if (fresh != null) {
2467
+ licenseState.isPaying = fresh;
2468
+ entitlementCache.set(licenseState.email, { isPaying: fresh, checkedAt: Date.now() });
2469
+ }
2470
+ return json(res, 200, { licensed: true, isPaying: licenseState.isPaying });
2471
+ }
2472
+
2473
+ if (url.pathname === '/api/projects' && req.method === 'POST') {
2474
+ let body = '';
2475
+ req.on('data', (d) => { body += d; });
2476
+ req.on('end', () => {
2477
+ try {
2478
+ const input = JSON.parse(body || '{}');
2479
+ const base = projects.find((p) => p.id === input.baseProjectId) ?? projects[0];
2480
+ const nameBase = String(input.name ?? 'New project').trim().slice(0, 80) || 'New project';
2481
+ let name = nameBase, n = 2;
2482
+ while (projects.some((p) => p.name === name)) name = `${nameBase} ${n++}`;
2483
+ const dir = typeof input.dir === 'string' && input.dir.trim() ? input.dir.trim() : (base?.dir ?? ROOT);
2484
+ const project = { id: uniqueProjectId(name), name, dir };
2485
+ projects.push(project);
2486
+ queues.set(project.id, { running: new Set(), waiting: [] });
2487
+ saveProjects();
2488
+ send({ ev: 'projects', projects });
2489
+ return json(res, 200, { project, projects });
2490
+ } catch { return json(res, 400, { error: 'bad json' }); }
2491
+ });
2492
+ return;
2493
+ }
2494
+
2495
+ const projectMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)$/);
2496
+ if (projectMatch && (req.method === 'PUT' || req.method === 'DELETE')) {
2497
+ const project = projects.find((p) => p.id === projectMatch[1]);
2498
+ if (!project) return json(res, 404, { error: 'project not found' });
2499
+ if (req.method === 'DELETE') {
2500
+ if (projects.length <= 1) return json(res, 409, { error: 'cannot delete the last project' });
2501
+ const active = goals.some((g) => g.projectId === project.id && ['planning', 'running', 'retesting'].includes(g.status))
2502
+ || tasks.some((t) => t.projectId === project.id && ['running', 'queued'].includes(t.status));
2503
+ if (active) return json(res, 409, { error: 'cannot delete a project with active work' });
2504
+ const idx = projects.findIndex((p) => p.id === project.id);
2505
+ projects.splice(idx, 1);
2506
+ queues.delete(project.id);
2507
+ saveProjects();
2508
+ send({ ev: 'projects', projects, deletedProjectId: project.id });
2509
+ return json(res, 200, { ok: true, projects, deletedProjectId: project.id });
2510
+ }
2511
+ let body = '';
2512
+ req.on('data', (d) => { body += d; });
2513
+ req.on('end', () => {
2514
+ try {
2515
+ const input = JSON.parse(body || '{}');
2516
+ if (typeof input.name === 'string' && input.name.trim()) project.name = input.name.trim().slice(0, 80);
2517
+ saveProjects();
2518
+ send({ ev: 'projects', projects });
2519
+ return json(res, 200, { project, projects });
2520
+ } catch { return json(res, 400, { error: 'bad json' }); }
2521
+ });
2522
+ return;
2523
+ }
2524
+
2525
+ // proof artifacts: /proof/<taskId>/<file> (gif/png/mp4 from that task's proof dir)
2526
+ const proofMatch = url.pathname.match(/^\/proof\/(\d+)\/([\w.-]+)$/);
2527
+ if (proofMatch) {
2528
+ const file = join(logDir, `proof-${proofMatch[1]}`, proofMatch[2]);
2529
+ if (!existsSync(file)) { res.writeHead(404); return res.end('not found'); }
2530
+ const type = file.endsWith('.gif') ? 'image/gif' : file.endsWith('.png') ? 'image/png'
2531
+ : file.endsWith('.mp4') ? 'video/mp4' : 'application/octet-stream';
2532
+ // proof artifacts are immutable per attempt — let the browser cache them
2533
+ // instead of re-streaming megabytes through the tunnel on every render
2534
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=86400' });
2535
+ return res.end(readFileSync(file));
2536
+ }
2537
+
2538
+ if (url.pathname === '/api/events') {
2539
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
2540
+ res.write('\n');
2541
+ sseClients.set(res, identityFor(req));
2542
+ req.on('close', () => sseClients.delete(res));
2543
+ return;
2544
+ }
2545
+
2546
+ if (url.pathname === '/api/tasks' && req.method === 'POST') {
2547
+ let body = '';
2548
+ req.on('data', (d) => { body += d; });
2549
+ req.on('end', async () => {
2550
+ try {
2551
+ const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
2552
+ const project = projects.find((p) => p.id === projectId);
2553
+ if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
2554
+ // Dedupe (Masa: 被ったやつもまとめて): if this near-duplicates a goal
2555
+ // already in flight (running/review with a live worker session), fold it
2556
+ // in as a thread follow-up (context kept via session resume) instead of
2557
+ // piling up a redundant parallel goal. Conservative — HIGH-confidence
2558
+ // text overlap only, against session-bearing active goals — so a
2559
+ // genuinely distinct ask always becomes its own goal, and the user's
2560
+ // text is never dropped (it becomes a reply task either way). Skipped for
2561
+ // pending (shelved) and review-only (human-only) items.
2562
+ const identity = identityFor(req);
2563
+ if (!pending && !review) {
2564
+ // Stage 2: only fold into a goal this identity can actually see —
2565
+ // otherwise a near-duplicate message from user B could silently
2566
+ // attach as a reply task onto someone else's private goal (and
2567
+ // leak that goal's text back to B in the response).
2568
+ const candidates = goals.filter((g) => g.projectId === projectId && g.sessionId && ['running', 'review'].includes(g.status) && goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL));
2569
+ const overlap = findOverlappingGoal(text.trim(), candidates);
2570
+ if (overlap) {
2571
+ // A follow-up on a goal already awaiting review re-opens work on it.
2572
+ if (overlap.goal.status === 'review') { overlap.goal.status = 'running'; saveGoal(overlap.goal); }
2573
+ const replyTask = queueReplyTask(overlap.goal, text.trim().slice(0, 8000));
2574
+ return json(res, 200, { ...overlap.goal, mergedInto: overlap.goal.id, replyTaskId: replyTask.id, overlapScore: overlap.score });
2575
+ }
2576
+ }
2577
+ // Billing (docs/BILLING-LAUNCH-PLAN.md §2/§5): gate actual new-goal
2578
+ // creation on the free tier / paid entitlement. Placed after the
2579
+ // dedupe fold-in above on purpose — folding into an existing goal as
2580
+ // a reply doesn't create a new goal, so it doesn't count against the
2581
+ // cap or need a gate check.
2582
+ const gate = await requireEntitlementGate(identity);
2583
+ if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
2584
+ // review-only = a "please look at / approve this" item for a human. It
2585
+ // lands straight in the Review lane and NEVER spawns a worker (no plan,
2586
+ // no PR) — the reviewer just Approves (files it) or Dismisses (drops
2587
+ // it). This is how "needs my judgment" reaches the phone without kicking
2588
+ // off an implementation run.
2589
+ const goalAgent = workerAgent(agent);
2590
+ const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
2591
+ const goal = {
2592
+ id: nextId++, projectId, text: text.trim().slice(0, 8000),
2593
+ status: review ? 'review' : pending ? 'pending' : 'planning',
2594
+ reviewOnly: review ? true : undefined,
2595
+ note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
2596
+ wantsPR: review ? false : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
2597
+ model: workerModel(goalAgent, model),
2598
+ effort: workerEffort(goalAgent, effort),
2599
+ agent: goalAgent,
2600
+ // MODE (Auto/Plan): plan = propose a plan first, approve to execute.
2601
+ // review-only goals never run a worker, so mode is moot → 'auto'.
2602
+ mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
2603
+ // SKILL: an optional user/project skill (or command) to invoke first.
2604
+ skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
2605
+ images: Array.isArray(images) ? images.slice(0, 6) : [],
2606
+ source: resolveGoalSource(source), sourceRef: null,
2607
+ executionPlan: buildExecutionPlan({
2608
+ projectId,
2609
+ text: priorFailureMemory.length ? `${latestFailurePolicy(priorFailureMemory)?.text ?? ''}\n\n${text.trim()}` : text.trim(),
2610
+ previousSession: false,
2611
+ }),
2612
+ priorFailureMemory: priorFailureMemory.length ? priorFailureMemory : undefined,
2613
+ plan: null, pr: undefined, createdAt: new Date().toISOString(),
2614
+ // Stage 2 (multi-user): who this goal belongs to — 'owner' for
2615
+ // key-auth/MANAGER_OWNER_EMAIL, else the creator's Google email.
2616
+ owner: identity,
2617
+ };
2618
+ goals.push(goal);
2619
+ saveGoal(goal);
2620
+ // pending = deliberately shelved (Phase 2 material): never planned,
2621
+ // never queued, until POST /api/goals/:id/activate. review-only items
2622
+ // are terminal-until-human too, so they also skip planGoal.
2623
+ if (!pending && !review) {
2624
+ planGoal(goal).catch((e) => {
2625
+ goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
2626
+ });
2627
+ }
2628
+ json(res, 200, goal);
2629
+ } catch { json(res, 400, { error: 'bad json' }); }
2630
+ });
2631
+ return;
2632
+ }
2633
+
2634
+ // Ask: a free-text question about the current Review/Attention/Done state,
2635
+ // answered in place (not the chat) — cheap LLM pass, no tools, no file access.
2636
+ if (url.pathname === '/api/ask' && req.method === 'POST') {
2637
+ let body = '';
2638
+ req.on('data', (d) => { body += d; });
2639
+ req.on('end', async () => {
2640
+ let question;
2641
+ try { ({ question } = JSON.parse(body)); } catch { return json(res, 400, { error: 'bad json' }); }
2642
+ if (!question?.trim()) return json(res, 400, { error: 'question required' });
2643
+ try {
2644
+ // Stage 2: Ask must never answer from another user's goals/tasks.
2645
+ const identity = identityFor(req);
2646
+ const askGoals = goals.filter((g) => goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL));
2647
+ const askGoalIds = new Set(askGoals.map((g) => g.id));
2648
+ const askTasks = tasks.filter((t) => t.goalId == null || askGoalIds.has(t.goalId));
2649
+ const context = buildAskContext(askGoals, askTasks);
2650
+ const prompt = [
2651
+ '以下はコーディングタスク管理ツールの現在の状態一覧(Review=レビュー待ち/Attention=要対応/Done=完了)です。',
2652
+ 'この情報だけを根拠に、ユーザーの質問に日本語で簡潔に答えてください。前置きや説明は書かず、答えだけを書いてください。',
2653
+ '',
2654
+ context,
2655
+ '',
2656
+ `質問: ${question.trim().slice(0, 500)}`,
2657
+ ].join('\n');
2658
+ const r = await runClaude({ prompt, cwd: ROOT, tools: 'Read', model: 'haiku' });
2659
+ json(res, 200, { answer: r.result.trim().slice(0, 2000) });
2660
+ } catch (e) {
2661
+ json(res, 500, { error: String(e.message ?? e).slice(0, 200) });
2662
+ }
2663
+ });
2664
+ return;
2665
+ }
2666
+
2667
+ // Goals can be edited (until work meaningfully starts) or deleted/cancelled
2668
+ // (up to and including a running goal) — see canEditGoal / canDeleteGoal.
2669
+ const goalMatch = url.pathname.match(/^\/api\/goals\/(\d+)$/);
2670
+ if (goalMatch && (req.method === 'PUT' || req.method === 'DELETE')) {
2671
+ const goal = goals.find((g) => g.id === Number(goalMatch[1]));
2672
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2673
+ if (!requireGoalOwnership(req, res, goal)) return;
2674
+ if (req.method === 'DELETE') {
2675
+ if (!canDeleteGoal(goal.status)) return json(res, 409, { error: `a ${goal.status} goal cannot be deleted` });
2676
+ // Running goal → clean cancel: SIGTERM its worker child, drop its queued
2677
+ // tasks, mark unfinished tasks cancelled. (No-op stop for not-yet-started
2678
+ // states.) The killed child's runTask frees its running-slot; we also
2679
+ // nudge the pipeline so a stacked/queued sibling starts.
2680
+ cancelGoalWork(goal);
2681
+ for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'interrupted', 'failed'].includes(t.status))) {
2682
+ t.status = 'skipped';
2683
+ t.result = t.result ?? 'deleted';
2684
+ saveTask(t);
2685
+ }
2686
+ const q = queues.get(goal.projectId);
2687
+ if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
2688
+ goals = goals.filter((g) => g.id !== goal.id);
2689
+ appendFileSync(logFile, JSON.stringify({ kind: 'goal', id: goal.id, deleted: true }) + '\n');
2690
+ sendGoalEvent({ ev: 'goal-deleted', id: goal.id }, goal);
2691
+ startNextStacked(goal.projectId);
2692
+ pump(goal.projectId);
2693
+ return json(res, 200, { ok: true });
2694
+ }
2695
+ if (!canEditGoal(goal.status, goal.startedAt)) return json(res, 409, { error: `a ${goal.status} goal cannot be edited` });
2696
+ let body = '';
2697
+ req.on('data', (d) => { body += d; });
2698
+ req.on('end', () => {
2699
+ try {
2700
+ const { text, pr, promote } = JSON.parse(body);
2701
+ if (promote) goal.prio = ++prioCounter;
2702
+ if (text?.trim()) {
2703
+ goal.text = text.trim().slice(0, 8000);
2704
+ goal.wantsPR = resolveWantsPR(pr, goal.text, getReviewDefinition(goal.projectId).defaultWantsPR);
2705
+ }
2706
+ saveGoal(goal);
2707
+ json(res, 200, goal);
2708
+ } catch { json(res, 400, { error: 'bad json' }); }
2709
+ });
2710
+ return;
2711
+ }
2712
+
2713
+ // retry an interrupted/failed task (same task object, fresh run)
2714
+ const retryMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/retry$/);
2715
+ if (retryMatch && req.method === 'POST') {
2716
+ const task = tasks.find((t) => t.id === Number(retryMatch[1]));
2717
+ if (!task) return json(res, 404, { error: 'task not found' });
2718
+ if (!requireTaskGoalOwnership(req, res, task)) return;
2719
+ if (!['interrupted', 'failed'].includes(task.status)) return json(res, 409, { error: 'only interrupted/failed tasks can be retried' });
2720
+ task.status = 'queued'; task.result = null; task.secs = null; task.proof = null;
2721
+ saveTask(task);
2722
+ const goal = goals.find((g) => g.id === task.goalId);
2723
+ // goal 427: 'blocked' now also covers a run-budget stop or a rate-limit
2724
+ // pause (goal.blocked.kind === 'budget'/'rate-limit') — a human hitting
2725
+ // Retry on the interrupted task is an explicit "yes, keep going" decision,
2726
+ // so clear the block and, for a budget stop specifically, give the
2727
+ // resumed run a fresh budget window (otherwise it would immediately
2728
+ // re-trip the same limit it just got manually overridden past).
2729
+ if (goal && ['partial', 'failed', 'interrupted', 'done', 'blocked'].includes(goal.status)) {
2730
+ const wasBudget = goal.blocked?.kind === 'budget';
2731
+ goal.status = 'running';
2732
+ goal.blocked = null;
2733
+ if (wasBudget) { goal.runElapsedMs = 0; goal.runTokens = 0; goal.runAttempts = 0; goal.runUsage = null; }
2734
+ saveGoal(goal);
2735
+ }
2736
+ queues.get(task.projectId).waiting.push(task);
2737
+ pump(task.projectId);
2738
+ return json(res, 200, task);
2739
+ }
2740
+
2741
+ // backfill a snapshot proof for a finished task (old reviews predate
2742
+ // automatic UI snapshots and show "No capture yet" otherwise)
2743
+ const snapMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/snapshot$/);
2744
+ if (snapMatch && req.method === 'POST') {
2745
+ const task = tasks.find((t) => t.id === Number(snapMatch[1]));
2746
+ if (!task) return json(res, 404, { error: 'task not found' });
2747
+ if (!requireTaskGoalOwnership(req, res, task)) return;
2748
+ const project = projects.find((p) => p.id === task.projectId);
2749
+ if (!project || !canSnapshot(project)) return json(res, 409, { error: 'project cannot be snapshotted' });
2750
+ captureSnapshotProof(task, project)
2751
+ .then(() => json(res, 200, task))
2752
+ .catch((e) => json(res, 500, { error: String(e.message ?? e).slice(0, 200) }));
2753
+ return;
2754
+ }
2755
+
2756
+ // close a stalled task without running it (failure-taxonomy: skipped)
2757
+ const skipMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/skip$/);
2758
+ if (skipMatch && req.method === 'POST') {
2759
+ const task = tasks.find((t) => t.id === Number(skipMatch[1]));
2760
+ if (!task) return json(res, 404, { error: 'task not found' });
2761
+ if (!requireTaskGoalOwnership(req, res, task)) return;
2762
+ if (!['interrupted', 'failed', 'queued'].includes(task.status)) return json(res, 409, { error: 'only stalled tasks can be skipped' });
2763
+ let body = '';
2764
+ req.on('data', (d) => { body += d; });
2765
+ req.on('end', () => {
2766
+ let reason = 'Skipped.';
2767
+ try { reason = JSON.parse(body).reason ?? reason; } catch {}
2768
+ task.status = 'skipped'; task.result = reason;
2769
+ const q = queues.get(task.projectId);
2770
+ q.waiting = q.waiting.filter((t) => t.id !== task.id);
2771
+ saveTask(task);
2772
+ const goal = goals.find((g) => g.id === task.goalId);
2773
+ const project = projects.find((p) => p.id === task.projectId);
2774
+ if (goal && project) finishGoalIfComplete(goal, project).catch(() => {});
2775
+ pump(task.projectId);
2776
+ json(res, 200, task);
2777
+ });
2778
+ return;
2779
+ }
2780
+
2781
+ // drag & drop ordering: stacked goals / queued tasks
2782
+ if (url.pathname === '/api/goals/reorder' && req.method === 'PUT') {
2783
+ let body = '';
2784
+ req.on('data', (d) => { body += d; });
2785
+ req.on('end', () => {
2786
+ try {
2787
+ const { ids } = JSON.parse(body);
2788
+ if (!Array.isArray(ids)) return json(res, 400, { error: 'ids required' });
2789
+ const identity = identityFor(req);
2790
+ // Drag reorder only ever contains ids the client rendered for this
2791
+ // identity in the first place — silently skip anything foreign
2792
+ // instead of 403ing the whole batch (a non-owner id here would only
2793
+ // ever be a bug, not a real client action).
2794
+ ids.forEach((id, i) => {
2795
+ const g = goals.find((x) => x.id === Number(id) && x.status === 'stacked');
2796
+ if (g && goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL)) { g.prio = ids.length - i; saveGoal(g); }
2797
+ });
2798
+ prioCounter = Math.max(prioCounter, ids.length);
2799
+ json(res, 200, { ok: true });
2800
+ } catch { json(res, 400, { error: 'bad json' }); }
2801
+ });
2802
+ return;
2803
+ }
2804
+ if (url.pathname === '/api/queue/reorder' && req.method === 'PUT') {
2805
+ let body = '';
2806
+ req.on('data', (d) => { body += d; });
2807
+ req.on('end', () => {
2808
+ try {
2809
+ const { projectId, ids } = JSON.parse(body);
2810
+ const q = queues.get(projectId);
2811
+ if (!q || !Array.isArray(ids)) return json(res, 400, { error: 'projectId and ids required' });
2812
+ // The queue itself is shared per-project (unchanged), but a drag
2813
+ // reorder must not let a non-owner move someone else's task — drop
2814
+ // any id whose task belongs to a goal this identity can't see; it
2815
+ // then simply keeps its current position (orderByIds leaves ids it
2816
+ // wasn't given untouched).
2817
+ const identity = identityFor(req);
2818
+ const ownIds = ids.filter((id) => {
2819
+ const t = q.waiting.find((x) => x.id === Number(id));
2820
+ const g = t && goals.find((gg) => gg.id === t.goalId);
2821
+ return !g || goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL);
2822
+ });
2823
+ // Only reshuffles queued tasks; a task already running was shifted
2824
+ // out of q.waiting by pump() and is never touched here. Re-applying
2825
+ // the priority sort keeps a drag from ever placing a task ahead of a
2826
+ // higher-priority one — it can only reorder within its own band.
2827
+ q.waiting = sortQueueByPriority(reorderQueue(q.waiting, ownIds));
2828
+ for (const t of q.waiting) saveTask(t); // broadcast new order
2829
+ json(res, 200, { ok: true, order: q.waiting.map((t) => t.id) });
2830
+ } catch { json(res, 400, { error: 'bad json' }); }
2831
+ });
2832
+ return;
2833
+ }
2834
+
2835
+ // change a queued task's priority (高/中/低) — re-sorts its project's queue
2836
+ // so a bumped-up task actually runs sooner, not just displays that way.
2837
+ const priorityMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/priority$/);
2838
+ if (priorityMatch && req.method === 'PUT') {
2839
+ const task = tasks.find((t) => t.id === Number(priorityMatch[1]));
2840
+ if (!task) return json(res, 404, { error: 'task not found' });
2841
+ if (!requireTaskGoalOwnership(req, res, task)) return;
2842
+ let body = '';
2843
+ req.on('data', (d) => { body += d; });
2844
+ req.on('end', () => {
2845
+ try {
2846
+ const { priority } = JSON.parse(body);
2847
+ if (!TASK_PRIORITIES.includes(priority)) return json(res, 400, { error: `priority must be one of: ${TASK_PRIORITIES.join(', ')}` });
2848
+ task.priority = priority;
2849
+ saveTask(task);
2850
+ const q = queues.get(task.projectId);
2851
+ if (q) { q.waiting = sortQueueByPriority(q.waiting); for (const t of q.waiting) saveTask(t); }
2852
+ json(res, 200, task);
2853
+ } catch { json(res, 400, { error: 'bad json' }); }
2854
+ });
2855
+ return;
2856
+ }
2857
+
2858
+ // PRなしレビューのチェックリストが全件Approveされたら次の状態へ進める (task 62)
2859
+ const advanceMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/advance-review$/);
2860
+ if (advanceMatch && req.method === 'POST') {
2861
+ const goal = goals.find((g) => g.id === Number(advanceMatch[1]));
2862
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2863
+ if (!requireGoalOwnership(req, res, goal)) return;
2864
+ const result = advanceReviewGoal(goal);
2865
+ if (!result.ok) return json(res, 409, { error: result.error });
2866
+ goal.status = result.status;
2867
+ saveGoal(goal);
2868
+ return json(res, 200, goal);
2869
+ }
2870
+
2871
+ // task 97: doneに進めたPRなしレビューゴールを review へ戻す(『元に戻す』)。
2872
+ // advance-review の逆操作。押し間違いのApproveを取り消せるようにする。
2873
+ const revertMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/revert-review$/);
2874
+ if (revertMatch && req.method === 'POST') {
2875
+ const goal = goals.find((g) => g.id === Number(revertMatch[1]));
2876
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2877
+ if (!requireGoalOwnership(req, res, goal)) return;
2878
+ const result = revertReviewGoal(goal);
2879
+ if (!result.ok) return json(res, 409, { error: result.error });
2880
+ goal.status = result.status;
2881
+ saveGoal(goal);
2882
+ return json(res, 200, goal);
2883
+ }
2884
+
2885
+ // Pending (deliberately shelved) goals: activate moves one into the normal
2886
+ // breakdown-first flow; pend shelves a stacked goal.
2887
+ const activateMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/activate$/);
2888
+ if (activateMatch && req.method === 'POST') {
2889
+ const goal = goals.find((g) => g.id === Number(activateMatch[1]));
2890
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2891
+ if (!requireGoalOwnership(req, res, goal)) return;
2892
+ if (goal.status !== 'pending') return json(res, 409, { error: 'only pending goals can be activated' });
2893
+ goal.status = 'planning'; saveGoal(goal);
2894
+ planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
2895
+ return json(res, 200, goal);
2896
+ }
2897
+ const pendMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/pend$/);
2898
+ if (pendMatch && req.method === 'POST') {
2899
+ const goal = goals.find((g) => g.id === Number(pendMatch[1]));
2900
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2901
+ if (!requireGoalOwnership(req, res, goal)) return;
2902
+ if (goal.status !== 'stacked') return json(res, 409, { error: 'only stacked goals can be pended' });
2903
+ goal.status = 'pending'; saveGoal(goal);
2904
+ return json(res, 200, goal);
2905
+ }
2906
+
2907
+ // Slack-style thread reply on a goal: resumes the goal's worker session
2908
+ // Smart intake: answer a planner clarifying question → fold it into the goal
2909
+ // and re-plan (no new worker; same goal continues to decomposition).
2910
+ const answerMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/answer$/);
2911
+ if (answerMatch && req.method === 'POST') {
2912
+ const goal = goals.find((g) => g.id === Number(answerMatch[1]));
2913
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2914
+ if (!requireGoalOwnership(req, res, goal)) return;
2915
+ if (goal.status !== 'needsInput') return json(res, 409, { error: 'goal is not awaiting input' });
2916
+ let body = '';
2917
+ req.on('data', (d) => { body += d; });
2918
+ req.on('end', () => {
2919
+ let answer = '';
2920
+ try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
2921
+ if (!answer) return json(res, 400, { error: 'answer required' });
2922
+ goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
2923
+ goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
2924
+ goal.question = null;
2925
+ goal.status = 'planning';
2926
+ saveGoal(goal);
2927
+ planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
2928
+ json(res, 200, goal);
2929
+ });
2930
+ return;
2931
+ }
2932
+
2933
+ const replyMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reply$/);
2934
+ if (replyMatch && req.method === 'POST') {
2935
+ const goal = goals.find((g) => g.id === Number(replyMatch[1]));
2936
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2937
+ if (!requireGoalOwnership(req, res, goal)) return;
2938
+ if (['stacked', 'planning'].includes(goal.status)) return json(res, 409, { error: 'goal not started yet — edit it instead' });
2939
+ let body = '';
2940
+ req.on('data', (d) => { body += d; });
2941
+ req.on('end', () => {
2942
+ try {
2943
+ const { text } = JSON.parse(body);
2944
+ if (!text?.trim()) return json(res, 400, { error: 'text required' });
2945
+ if (['partial', 'failed', 'interrupted', 'blocked'].includes(goal.status)) {
2946
+ goal.status = 'running';
2947
+ goal.blocked = null;
2948
+ saveGoal(goal);
2949
+ }
2950
+ json(res, 200, queueReplyTask(goal, text.trim()));
2951
+ } catch { json(res, 400, { error: 'bad json' }); }
2952
+ });
2953
+ return;
2954
+ }
2955
+
2956
+ // レビュー詳細パネル(task 70)のApproveボタン: レビュー中のgoalをdoneへ確定する。
2957
+ // advance-review(task 62)のチェックリスト全件Approveとは別の、単発の人間の判断。
2958
+ const approveMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/approve$/);
2959
+ if (approveMatch && req.method === 'POST') {
2960
+ const goal = goals.find((g) => g.id === Number(approveMatch[1]));
2961
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2962
+ if (!requireGoalOwnership(req, res, goal)) return;
2963
+ // Plan-review approve: do NOT file to Done — re-queue the SAME goal to
2964
+ // EXECUTE the approved plan. Flip to auto (acceptEdits), mark executing, and
2965
+ // reset this goal's tasks back to 'queued' so the worker now actually edits
2966
+ // (with the approved plan already in its resumed session as context).
2967
+ if (isPlanReview(goal)) {
2968
+ const next = nextAfterPlanApprove(goal);
2969
+ if (!next.ok) return json(res, 409, { error: next.error });
2970
+ goal.mode = next.mode;
2971
+ goal.planPhase = next.planPhase;
2972
+ goal.status = next.status;
2973
+ goal.blocked = null;
2974
+ saveGoal(goal);
2975
+ const q = queues.get(goal.projectId);
2976
+ for (const t of tasks.filter((t) => t.goalId === goal.id)) {
2977
+ t.status = 'queued'; t.mode = 'auto';
2978
+ t.result = null; t.secs = null; t.proof = null; t.changedFiles = [];
2979
+ saveTask(t);
2980
+ if (q) q.waiting.push(t);
2981
+ }
2982
+ pump(goal.projectId);
2983
+ return json(res, 200, goal);
2984
+ }
2985
+ const result = approveGoal({ goalStatus: goal.status });
2986
+ if (!result.ok) return json(res, 409, { error: result.error });
2987
+ goal.status = result.status;
2988
+ saveGoal(goal);
2989
+ return json(res, 200, goal);
2990
+ }
2991
+
2992
+ // Re-run the Manager-verified gate for a blocked goal (tests + proof), from
2993
+ // Attention's 再検証 button. Passes → review; still failing → stays blocked.
2994
+ const reverifyMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reverify$/);
2995
+ if (reverifyMatch && req.method === 'POST') {
2996
+ const goal = goals.find((g) => g.id === Number(reverifyMatch[1]));
2997
+ if (!goal) return json(res, 404, { error: 'goal not found' });
2998
+ if (!requireGoalOwnership(req, res, goal)) return;
2999
+ const project = projects.find((p) => p.id === goal.projectId);
3000
+ if (!project) return json(res, 404, { error: 'project not found' });
3001
+ const siblings = tasks.filter((t) => t.goalId === goal.id);
3002
+ const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
3003
+ await verifyGate(goal, { ...project, dir: wdir }, siblings, project);
3004
+ saveGoal(goal);
3005
+ return json(res, 200, goal);
3006
+ }
3007
+
3008
+ // Archive: retire an UNFINISHED goal (partial/failed/interrupted/blocked)
3009
+ // cleanly — spawning no rework. DELETE only accepts stacked/pending, and
3010
+ // dismiss on a partial goal queues a "差し戻し" rework task, so a goal that
3011
+ // half-finished (a subtask failed/interrupted) had no clean exit and piled up
3012
+ // forever between done and cleared. Archive puts the goal AND any of its
3013
+ // non-terminal child tasks to rest as 'skipped', clearing Attention for good.
3014
+ // Ledger (v45 §5.1/PRD §5.1) adds 'review' to the archivable statuses:
3015
+ // Archive = 判断保留の棚上げ, neither Done nor a dismissal. archivedFrom is
3016
+ // kept so the Ledger one-liner's UNDO (/unarchive) can restore it.
3017
+ const archiveMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/archive$/);
3018
+ if (archiveMatch && req.method === 'POST') {
3019
+ const goal = goals.find((g) => g.id === Number(archiveMatch[1]));
3020
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3021
+ if (!requireGoalOwnership(req, res, goal)) return;
3022
+ const result = archiveGoal({ goalStatus: goal.status });
3023
+ if (!result.ok) return json(res, 409, { error: result.error });
3024
+ // Retire leftover work — but never touch a task mid-run (no worker kill).
3025
+ for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'interrupted', 'failed'].includes(t.status))) {
3026
+ t.status = 'skipped'; t.result = t.result ?? 'archived'; saveTask(t);
3027
+ }
3028
+ goal.archivedFrom = result.archivedFrom;
3029
+ goal.status = result.status; goal.blocked = null; saveGoal(goal);
3030
+ sendGoalEvent({ ev: 'goal-archived', id: goal.id }, goal);
3031
+ return json(res, 200, { ok: true, goal });
3032
+ }
3033
+
3034
+ // UNDO of a Ledger Archive — honest scope: only goals archived FROM review
3035
+ // (see unarchiveGoal in lib.mjs for why unfinished-goal archives stay put).
3036
+ const unarchiveMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/unarchive$/);
3037
+ if (unarchiveMatch && req.method === 'POST') {
3038
+ const goal = goals.find((g) => g.id === Number(unarchiveMatch[1]));
3039
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3040
+ if (!requireGoalOwnership(req, res, goal)) return;
3041
+ const result = unarchiveGoal({ goalStatus: goal.status, archivedFrom: goal.archivedFrom });
3042
+ if (!result.ok) return json(res, 409, { error: result.error });
3043
+ goal.status = result.status; goal.archivedFrom = null; saveGoal(goal);
3044
+ return json(res, 200, goal);
3045
+ }
3046
+
3047
+ // Ledger plain Dismiss (v45 §5.3): start the automatic retest ×3 job. The
3048
+ // dismiss-with-text (chat) path stays on /dismiss below.
3049
+ const retestMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/retest$/);
3050
+ if (retestMatch && req.method === 'POST') {
3051
+ const goal = goals.find((g) => g.id === Number(retestMatch[1]));
3052
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3053
+ if (!requireGoalOwnership(req, res, goal)) return;
3054
+ const result = retestGoal({ goalStatus: goal.status });
3055
+ if (!result.ok) return json(res, 409, { error: result.error });
3056
+ goal.status = result.status;
3057
+ goal.retest = { state: 'running', runs: 0, total: RETEST_RUNS, startedAt: new Date().toISOString() };
3058
+ saveGoal(goal);
3059
+ const token = `${goal.id}-${Date.now()}-${Math.random()}`;
3060
+ retestTokens.set(goal.id, token);
3061
+ runRetestJob(goal, token).catch((e) => console.warn(`[manager] retest goal ${goal.id} failed: ${String(e).slice(0, 200)}`));
3062
+ return json(res, 200, goal);
3063
+ }
3064
+
3065
+ // UNDO of a Ledger plain Dismiss: cancel the in-flight retest. The running
3066
+ // job re-checks goal.status before applying results, so a cancelled retest
3067
+ // can never overwrite this.
3068
+ const retestCancelMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/retest-cancel$/);
3069
+ if (retestCancelMatch && req.method === 'POST') {
3070
+ const goal = goals.find((g) => g.id === Number(retestCancelMatch[1]));
3071
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3072
+ if (!requireGoalOwnership(req, res, goal)) return;
3073
+ const result = cancelRetestGoal({ goalStatus: goal.status });
3074
+ if (!result.ok) return json(res, 409, { error: result.error });
3075
+ goal.status = result.status; goal.retest = null; saveGoal(goal);
3076
+ return json(res, 200, goal);
3077
+ }
3078
+
3079
+ // Ledger Revert (ゴミ箱, v45 §5.3): make the change never have happened —
3080
+ // close the PR (+ delete its remote branch), drop the goal's worktree, mark
3081
+ // the goal 'reverted' (terminal, distinct from archived). If a destructive
3082
+ // step fails the goal is NOT marked reverted (never claim a revert that
3083
+ // didn't happen).
3084
+ const revertGoalMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/revert$/);
3085
+ if (revertGoalMatch && req.method === 'POST') {
3086
+ const goal = goals.find((g) => g.id === Number(revertGoalMatch[1]));
3087
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3088
+ if (!requireGoalOwnership(req, res, goal)) return;
3089
+ const project = projects.find((p) => p.id === goal.projectId);
3090
+ if (!project) return json(res, 404, { error: 'project not found' });
3091
+ const decision = revertGoal({ goalStatus: goal.status });
3092
+ if (!decision.ok) return json(res, 409, { error: decision.error });
3093
+ const actions = planRevertActions({ pr: goal.pr, prBranch: goal.prBranch, hasWorktree: goalWorkDirs.has(goal.id) });
3094
+ for (const a of actions) {
3095
+ try { await execRevertAction(a, goal, project); }
3096
+ catch (e) { return json(res, 502, { error: `revert incomplete (${a.kind}): ${String(e.message ?? e).slice(0, 300)}` }); }
3097
+ }
3098
+ goal.status = decision.status;
3099
+ goal.reverted = { at: new Date().toISOString(), actions: actions.map((a) => a.kind) };
3100
+ saveGoal(goal);
3101
+ return json(res, 200, goal);
3102
+ }
3103
+
3104
+ // UNDO of a Ledger chat send-back: pull the rework reply task back out of
3105
+ // the queue while it is still queued (honest — see undismissGoal).
3106
+ const undismissMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/undismiss$/);
3107
+ if (undismissMatch && req.method === 'POST') {
3108
+ const goal = goals.find((g) => g.id === Number(undismissMatch[1]));
3109
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3110
+ if (!requireGoalOwnership(req, res, goal)) return;
3111
+ const replyTask = [...tasks].reverse().find((t) => t.goalId === goal.id && t.reply);
3112
+ const result = undismissGoal({ goalStatus: goal.status, replyTask });
3113
+ if (!result.ok) return json(res, 409, { error: result.error });
3114
+ const q = queues.get(goal.projectId);
3115
+ if (q) q.waiting = q.waiting.filter((t) => t.id !== replyTask.id);
3116
+ replyTask.status = 'skipped'; replyTask.result = 'undone from the Ledger before it started'; saveTask(replyTask);
3117
+ goal.status = result.status; saveGoal(goal);
3118
+ return json(res, 200, { goal, task: replyTask });
3119
+ }
3120
+
3121
+ // Ledger Details data: diff stats + files + diff text for the goal's branch
3122
+ // (plain git, cached per branch tip in computeGoalDiff). 404 = the goal has
3123
+ // no branch/diff — the UI renders what exists and omits the rest honestly.
3124
+ const goalDiffMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/diff$/);
3125
+ if (goalDiffMatch && req.method === 'GET') {
3126
+ const goal = goals.find((g) => g.id === Number(goalDiffMatch[1]));
3127
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3128
+ if (!requireGoalOwnership(req, res, goal)) return;
3129
+ const project = projects.find((p) => p.id === goal.projectId);
3130
+ if (!project) return json(res, 404, { error: 'project not found' });
3131
+ const payload = computeGoalDiff(goal, project);
3132
+ if (!payload) return json(res, 404, { error: 'no diff available for this goal' });
3133
+ return json(res, 200, payload);
3134
+ }
3135
+
3136
+ // レビュー詳細パネルのDismissボタン: 修正指示(空でも「差し戻し」扱い)をgoalのスレッド
3137
+ // 返信としてworkerに渡し、goalをrunning(doing相当)へ戻す。
3138
+ const dismissMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/dismiss$/);
3139
+ if (dismissMatch && req.method === 'POST') {
3140
+ const goal = goals.find((g) => g.id === Number(dismissMatch[1]));
3141
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3142
+ if (!requireGoalOwnership(req, res, goal)) return;
3143
+ // review-only items + blocked goals have no worker thread to revise — Dismiss
3144
+ // just clears them (filed 'skipped'), spawning nothing.
3145
+ if (goal.reviewOnly || goal.status === 'blocked') {
3146
+ if (!['review', 'blocked'].includes(goal.status)) return json(res, 409, { error: 'goal is not dismissable' });
3147
+ goal.status = 'skipped'; goal.blocked = null;
3148
+ saveGoal(goal);
3149
+ return json(res, 200, { goal });
3150
+ }
3151
+ const result = dismissGoal({ goalStatus: goal.status });
3152
+ if (!result.ok) return json(res, 409, { error: result.error });
3153
+ let body = '';
3154
+ req.on('data', (d) => { body += d; });
3155
+ req.on('end', () => {
3156
+ let text = '';
3157
+ try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
3158
+ goal.status = result.status;
3159
+ saveGoal(goal);
3160
+ const task = queueReplyTask(goal, text || '差し戻し。修正してください。');
3161
+ json(res, 200, { goal, task });
3162
+ });
3163
+ return;
3164
+ }
3165
+
3166
+ // image attachments: upload (base64 JSON) + serve back for thumbnails
3167
+ if (url.pathname === '/api/upload' && req.method === 'POST') {
3168
+ let body = '';
3169
+ req.on('data', (d) => { body += d; });
3170
+ req.on('end', () => {
3171
+ try {
3172
+ const { name, dataBase64 } = JSON.parse(body);
3173
+ const safe = String(name ?? 'image.png').replace(/[^\w.-]/g, '_').slice(-80);
3174
+ const file = `${Date.now()}-${safe}`;
3175
+ const dir = join(logDir, 'uploads');
3176
+ mkdirSync(dir, { recursive: true });
3177
+ const buf = Buffer.from(String(dataBase64 ?? ''), 'base64');
3178
+ if (!buf.length || buf.length > 10 * 1024 * 1024) return json(res, 400, { error: 'empty or >10MB' });
3179
+ writeFileSync(join(dir, file), buf);
3180
+ json(res, 200, { path: join(dir, file), url: `/upload/${file}` });
3181
+ } catch { json(res, 400, { error: 'bad json' }); }
3182
+ });
3183
+ return;
3184
+ }
3185
+ const upMatch = url.pathname.match(/^\/upload\/([\w.-]+)$/);
3186
+ if (upMatch) {
3187
+ const file = join(logDir, 'uploads', upMatch[1]);
3188
+ if (!existsSync(file)) { res.writeHead(404); return res.end('not found'); }
3189
+ res.writeHead(200, { 'content-type': file.endsWith('.gif') ? 'image/gif' : file.endsWith('.jpg') || file.endsWith('.jpeg') ? 'image/jpeg' : 'image/png' });
3190
+ return res.end(readFileSync(file));
3191
+ }
3192
+
3193
+ // SKILL picker: list the skills/commands a worker can invoke for this project
3194
+ // — user-level (~/.claude) + project-level (<dir>/.claude). Read-only, no LLM.
3195
+ const skillsMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/skills$/);
3196
+ if (skillsMatch && req.method === 'GET') {
3197
+ const project = projects.find((p) => p.id === skillsMatch[1]);
3198
+ if (!project) return json(res, 404, { error: 'project not found' });
3199
+ const home = homedir();
3200
+ const sources = [
3201
+ { dir: join(home, '.claude', 'skills'), source: 'user', kind: 'skill' },
3202
+ { dir: join(home, '.claude', 'commands'), source: 'user', kind: 'command' },
3203
+ { dir: join(project.dir, '.claude', 'skills'), source: 'project', kind: 'skill' },
3204
+ { dir: join(project.dir, '.claude', 'commands'), source: 'project', kind: 'command' },
3205
+ ];
3206
+ return json(res, 200, { skills: collectSkills(sources) });
3207
+ }
3208
+
3209
+ // workflow columns: the Tasks-panel column editor's persistence (task 44)
3210
+ const colsMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/columns$/);
3211
+ if (colsMatch) {
3212
+ const project = projects.find((p) => p.id === colsMatch[1]);
3213
+ if (!project) return json(res, 404, { error: 'project not found' });
3214
+ if (req.method === 'GET') return json(res, 200, { columns: getColumns(project.id) });
3215
+ if (req.method === 'PUT') {
3216
+ let body = '';
3217
+ req.on('data', (d) => { body += d; });
3218
+ req.on('end', () => {
3219
+ try {
3220
+ const { columns } = JSON.parse(body);
3221
+ const result = validateWorkflowColumns(columns);
3222
+ if (!result.ok) return json(res, 400, { error: result.error });
3223
+ workflowColumns[project.id] = result.columns;
3224
+ saveWorkflowColumns();
3225
+ send({ ev: 'columns', projectId: project.id, columns: result.columns });
3226
+ json(res, 200, { columns: result.columns });
3227
+ } catch { json(res, 400, { error: 'bad json' }); }
3228
+ });
3229
+ return;
3230
+ }
3231
+ }
3232
+
3233
+ // review definition: what counts as "needs review" for a finished goal
3234
+ // (task 45), edited from the same settings panel as workflow columns
3235
+ const reviewDefMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/review-definition$/);
3236
+ if (reviewDefMatch) {
3237
+ const project = projects.find((p) => p.id === reviewDefMatch[1]);
3238
+ if (!project) return json(res, 404, { error: 'project not found' });
3239
+ if (req.method === 'GET') return json(res, 200, { reviewDefinition: getReviewDefinition(project.id) });
3240
+ if (req.method === 'PUT') {
3241
+ let body = '';
3242
+ req.on('data', (d) => { body += d; });
3243
+ req.on('end', () => {
3244
+ try {
3245
+ const { reviewDefinition } = JSON.parse(body);
3246
+ const result = validateReviewDefinition(reviewDefinition);
3247
+ if (!result.ok) return json(res, 400, { error: result.error });
3248
+ reviewDefinitions[project.id] = result.definition;
3249
+ saveReviewDefinitions();
3250
+ send({ ev: 'review-definition', projectId: project.id, reviewDefinition: result.definition });
3251
+ json(res, 200, { reviewDefinition: result.definition });
3252
+ } catch { json(res, 400, { error: 'bad json' }); }
3253
+ });
3254
+ return;
3255
+ }
3256
+ }
3257
+
3258
+ res.writeHead(404); res.end('not found');
3259
+ });
3260
+
3261
+ // IPv6 loopback (::1) twin. A Cloudflare/ngrok tunnel with origin "localhost"
3262
+ // resolves to ::1 first; binding IPv4-only (127.0.0.1) made that path 502.
3263
+ // Same request handler, loopback-only, best-effort (skip if ::1 unavailable).
3264
+ const server6 = createServer(server.listeners('request')[0]);
3265
+ server6.on('error', (e) => console.log(`[manager] ::1 listen skipped: ${e.code}`));
3266
+ try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
3267
+
3268
+ server.listen(PORT, '127.0.0.1', () => {
3269
+ console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
3270
+ if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
3271
+ spawn('open', ['-g', `http://localhost:${PORT}/?key=${ACCESS_KEY}`], { stdio: 'ignore' }).unref();
3272
+ }
3273
+ console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
3274
+ syncAllExternal().catch(() => {});
3275
+ syncGoalMerges().catch(() => {});
3276
+ setInterval(() => {
3277
+ send({ ev: 'ping', uiVersion: uiVersion() });
3278
+ syncGoalMerges().catch(() => {});
3279
+ for (const p of projects) {
3280
+ pump(p.id);
3281
+ for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
3282
+ g.status = 'planning'; saveGoal(g);
3283
+ planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
3284
+ }
3285
+ }
3286
+ }, 30_000).unref();
3287
+ for (const p of projects) {
3288
+ const pending = tasks.filter((t) => t.projectId === p.id && t.status === 'queued').sort((a, b) => a.num - b.num);
3289
+ const q = queues.get(p.id);
3290
+ q.waiting.push(...pending);
3291
+ if (pending.length) console.log(`[manager] ${p.id}: re-queued ${pending.length} pending task(s) after restart`);
3292
+ pump(p.id);
3293
+ // breakdown-first: any goal still sitting 'stacked' (legacy or crash
3294
+ // leftovers) gets planned immediately so it can never rot in the queue
3295
+ for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
3296
+ g.status = 'planning'; saveGoal(g);
3297
+ planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
3298
+ }
3299
+ // A restart must not orphan an in-flight Ledger retest (v45 §5.3): the
3300
+ // intent is persisted as status 'retesting' in tasks.jsonl (replayQueueLog
3301
+ // passes it through untouched) — resume the job from run 1.
3302
+ for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'retesting')) {
3303
+ const token = `${g.id}-${Date.now()}-${Math.random()}`;
3304
+ retestTokens.set(g.id, token);
3305
+ runRetestJob(g, token).catch(() => {});
3306
+ }
3307
+ // A crash between a goal's last task finishing and createGoalPR running
3308
+ // leaves it 'review' with no goal.pr and no prError yet (see
3309
+ // replayQueueLog) — finish that PR now instead of leaving it stuck.
3310
+ const orphanedReview = goals.filter((g) => g.projectId === p.id && g.status === 'review' && g.wantsPR && !g.pr && !g.prError);
3311
+ for (const g of orphanedReview) {
3312
+ createGoalPR(g, p, tasks.filter((t) => t.goalId === g.id)).catch(() => {});
3313
+ }
3314
+ }
3315
+ });