@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
package/engine/lib.mjs ADDED
@@ -0,0 +1,2378 @@
1
+ // Manager for AI — pure helpers (unit-tested in engine/test/)
2
+
3
+ import { resolve, join, basename } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { createPublicKey, verify as verifyRsaSignature } from 'node:crypto';
7
+
8
+ // Build the URL a headless-browser verifier should open for a goal's entry
9
+ // point. Plain http(s) entries pass through unchanged. A relative path is
10
+ // normally opened as a bare file:// page — but the Manager's own UI
11
+ // (managerRoot/app/index.html) talks to itself via relative fetch() calls
12
+ // (/api/tasks, /api/state, …), which resolve against the file:// origin and
13
+ // fail outright when opened that way. So that one entry must be checked
14
+ // against the live server URL instead.
15
+ // `accessKey`, when given, is appended as `?key=` for the Manager's own
16
+ // index.html — that page sits behind the server's ?key= auth gate (see
17
+ // server.mjs), so a verifier opening it without the key gets a 403 page
18
+ // instead of the real UI, and every window.state check on it fails.
19
+ export function resolveEntryUrl({ entry, projectDir, managerRoot, port, accessKey }) {
20
+ if (!entry) return null;
21
+ if (/^https?:\/\//.test(entry)) {
22
+ // direct http URLs to this Manager's own UI also need the key
23
+ if (accessKey && entry.includes(`localhost:${port}`) && !entry.includes('key=')) {
24
+ return `${entry}${entry.includes('?') ? '&' : '?'}key=${accessKey}`;
25
+ }
26
+ return entry;
27
+ }
28
+ const abs = resolve(join(projectDir, entry));
29
+ if (abs === resolve(managerRoot, 'app', 'index.html')) {
30
+ const base = `http://localhost:${port}/`;
31
+ return accessKey ? `${base}?key=${accessKey}` : base;
32
+ }
33
+ return pathToFileURL(abs).href;
34
+ }
35
+
36
+ // Extract token/cost usage from a parsed `type:'result'` stream-json event
37
+ // (the final event of a `claude -p --output-format stream-json` run).
38
+ // Returns null for any other event type.
39
+ export function parseResultUsage(ev) {
40
+ if (!ev || ev.type !== 'result') return null;
41
+ const u = ev.usage ?? {};
42
+ return {
43
+ input_tokens: u.input_tokens ?? null,
44
+ output_tokens: u.output_tokens ?? null,
45
+ cache_read_input_tokens: u.cache_read_input_tokens ?? null,
46
+ cache_creation_input_tokens: u.cache_creation_input_tokens ?? null,
47
+ cost_usd: ev.total_cost_usd ?? ev.cost_usd ?? null,
48
+ duration_ms: ev.duration_ms ?? null,
49
+ };
50
+ }
51
+
52
+ // Sum a list of per-attempt usage objects (as returned by parseResultUsage)
53
+ // into one totals object; null entries are skipped. Returns null when there
54
+ // is nothing to sum, so callers can store task.usage = sumUsage(...) as-is.
55
+ export function sumUsage(usages) {
56
+ const list = (usages ?? []).filter(Boolean);
57
+ if (!list.length) return null;
58
+ const keys = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'cost_usd', 'duration_ms'];
59
+ const out = {};
60
+ for (const k of keys) out[k] = list.reduce((sum, u) => sum + (u[k] ?? 0), 0);
61
+ return out;
62
+ }
63
+
64
+ // Render a task's summed usage (as returned by sumUsage) into the one-line
65
+ // "tokens in Xk / out Yk (flat-rate, no extra cost)" label shown on a
66
+ // finished task card. No $ figure: Manager for AI runs on the flat-rate
67
+ // Claude Code subscription, not metered API billing, so a per-task dollar
68
+ // cost would be misleading (see docs/internal/strategy-vs-goal.md).
69
+ // Mirrored manually in app/index.html's formatUsage() since that inline
70
+ // script can't import this module — keep both in sync.
71
+ // Returns null when there is no usage to show.
72
+ export function formatUsage(usage) {
73
+ if (!usage) return null;
74
+ const fmtK = (n) => (n == null ? '0' : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
75
+ return `tokens in ${fmtK(usage.input_tokens)} / out ${fmtK(usage.output_tokens)} (flat-rate, no extra cost)`;
76
+ }
77
+
78
+ // Compact total-token label for tight spaces (review-card badges, the
79
+ // header's today's-total figure) — just "12.3k", no unit/wording; the
80
+ // caller supplies those. Mirrored manually in app/index.html's
81
+ // formatUsageCompact() since that inline script can't import this module —
82
+ // keep both in sync. Returns null when there is no usage to show.
83
+ export function formatUsageCompact(usage) {
84
+ if (!usage) return null;
85
+ const fmtK = (n) => (n == null ? '0' : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
86
+ return fmtK((usage.input_tokens ?? 0) + (usage.output_tokens ?? 0));
87
+ }
88
+
89
+ // Sums usage across tasks that finished on the same local calendar day as
90
+ // `nowMs` (compared via toDateString(), so it's just "today" in whatever
91
+ // timezone the caller's clock is in) — powers the header's today's-total
92
+ // figure. `nowMs` is a parameter rather than read internally so the result
93
+ // stays deterministic/testable. Mirrored manually in app/index.html since
94
+ // that inline script can't import this module — keep both in sync. Returns
95
+ // null when nothing finished today or none of it carries usage.
96
+ export function sumUsageForToday(tasks, nowMs) {
97
+ const todayKey = new Date(nowMs).toDateString();
98
+ const usages = (tasks ?? [])
99
+ .filter((t) => t?.finishedAt && new Date(t.finishedAt).toDateString() === todayKey)
100
+ .map((t) => t.usage);
101
+ return sumUsage(usages);
102
+ }
103
+
104
+ // Token-frugal execution planning -------------------------------------------------
105
+ // A visible Project is the user's mental workspace. Internally, Manager can
106
+ // route goals into smaller workstreams and session policies so unrelated work
107
+ // does not inherit a huge session. These rules are examples, not a taxonomy:
108
+ // unknown topics get a task-derived workstream label instead of being forced
109
+ // into one of the named buckets.
110
+ const WORKSTREAM_RULES = [
111
+ { id: 'design', label: 'Design', re: /design|ui|ux|figma|visual|見た目|デザイン|レイアウト|配色|ボタン|画面|モバイル/i },
112
+ { id: 'research', label: 'Research', re: /marketing|market|seo|landing|copy|ads|growth|競合|人気|ユーザー数|調査|リサーチ|市場|top\s*\d+/i },
113
+ { id: 'docs', label: 'Docs', re: /docs?|prd|readme|spec|仕様|要件|ドキュメント|レポート|まとめ/i },
114
+ { id: 'backend', label: 'Backend', re: /api|server|engine|backend|db|auth|test|テスト|検証|削除|追加|修正|実装/i },
115
+ { id: 'frontend', label: 'Frontend', re: /frontend|app\/|html|css|javascript|typescript|react|vue|svelte/i },
116
+ ];
117
+
118
+ function derivedWorkstream(text = '') {
119
+ const cleaned = String(text ?? '')
120
+ .replace(/[^\p{L}\p{N}\s/_-]+/gu, ' ')
121
+ .trim();
122
+ const token = (cleaned.split(/\s+/).find((w) => w.length >= 3) ?? cleaned.slice(0, 18)) || 'task';
123
+ const id = token.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'task';
124
+ return { id: `task-${id}`, label: token.slice(0, 24) };
125
+ }
126
+
127
+ export function classifyWorkstream({ text = '', changedFiles = [] } = {}) {
128
+ const haystack = `${text}\n${(changedFiles ?? []).join('\n')}`;
129
+ return WORKSTREAM_RULES.find((r) => r.re.test(haystack)) ?? derivedWorkstream(text || (changedFiles ?? []).join(' '));
130
+ }
131
+
132
+ export function chooseSessionPolicy({ usage = null, runTokens = 0, runAttempts = 0, previousSession = false, text = '' } = {}) {
133
+ const fresh = (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0);
134
+ const cacheRead = usage?.cache_read_input_tokens ?? 0;
135
+ const cacheDominated = cacheRead > 500_000 && cacheRead > fresh * 5;
136
+ const hugeContext = cacheRead > 1_500_000 || Number(runTokens ?? 0) > 1_500_000;
137
+ const broadAsk = /全部|全体|あらゆる|まとめて|top\s*\d+|調査|リサーチ|設計|PRD|計画/i.test(String(text ?? ''));
138
+ if (!previousSession) return { mode: 'cold', reason: 'new independent goal' };
139
+ if (cacheDominated || hugeContext) return { mode: 'cold-handoff', reason: 'large cached context would be expensive to resume' };
140
+ if (runAttempts >= 4 || broadAsk) return { mode: 'compact', reason: 'keep useful summary, drop long transcript' };
141
+ return { mode: 'resume', reason: 'small focused follow-up' };
142
+ }
143
+
144
+ export function chooseWorkerPolicy({ text = '', changedFiles = [], risk = null } = {}) {
145
+ const workstream = classifyWorkstream({ text, changedFiles });
146
+ const haystack = `${text}\n${(changedFiles ?? []).join('\n')}`;
147
+ const docsOnly = changedFiles.length > 0 && !hasTestRelevantChanges(changedFiles);
148
+ const highRisk = risk?.shouldPause || /auth|payment|billing|database|migration|deploy|secret|認証|支払|課金|DB|デプロイ/i.test(haystack);
149
+ const research = workstream.id === 'research' || /調査|リサーチ|market|top\s*\d+/i.test(haystack);
150
+ const design = workstream.id === 'design';
151
+ const policy = highRisk
152
+ ? { effort: 'high', reason: 'safety-sensitive change needs stronger verification' }
153
+ : docsOnly || research
154
+ ? { effort: 'low', reason: 'docs/research path should stay lightweight' }
155
+ : design
156
+ ? { effort: 'medium', reason: 'UI/design work needs proof but not broad exploration' }
157
+ : { effort: 'medium', reason: 'default implementation path' };
158
+ return {
159
+ workstreamId: workstream.id,
160
+ workstreamLabel: workstream.label,
161
+ effort: policy.effort,
162
+ reason: policy.reason,
163
+ };
164
+ }
165
+
166
+ export function buildExecutionPlan({ projectId = null, text = '', usage = null, runTokens = 0, runAttempts = 0, previousSession = false } = {}) {
167
+ const workstream = classifyWorkstream({ text });
168
+ const session = chooseSessionPolicy({ usage, runTokens, runAttempts, previousSession, text });
169
+ const workerPolicy = chooseWorkerPolicy({ text });
170
+ const optimizationTechniques = [
171
+ session.mode === 'compact' ? 'compact handoff' : null,
172
+ session.mode === 'cold-handoff' ? 'fresh handoff' : null,
173
+ session.mode === 'cold' ? '/clear equivalent' : null,
174
+ workerPolicy.reason ? 'worker policy' : null,
175
+ ].filter(Boolean);
176
+ return {
177
+ projectGroupId: projectId,
178
+ workstreamId: workstream.id,
179
+ workstreamLabel: workstream.label,
180
+ sessionMode: session.mode,
181
+ sessionReason: session.reason,
182
+ workerPolicy,
183
+ optimizationTechniques,
184
+ };
185
+ }
186
+
187
+ export function buildContextHandoffSummary({ goal = null, tasks = [] } = {}) {
188
+ const reqs = (tasks ?? []).filter((t) => !t.reply);
189
+ const done = reqs.filter((t) => ['done', 'skipped'].includes(t.status)).length;
190
+ const files = [...new Set(reqs.flatMap((t) => t.changedFiles ?? []))]
191
+ .filter(Boolean)
192
+ .slice(0, 20);
193
+ const reports = reqs
194
+ .filter((t) => t.result || t.title)
195
+ .slice(-4)
196
+ .map((t) => `- #${t.num ?? '?'} ${String(t.title ?? '').replace(/\s+/g, ' ').slice(0, 80)}: ${String(t.result ?? t.status ?? '').replace(/\s+/g, ' ').slice(0, 220)}`);
197
+ return [
198
+ 'Manager handoff summary:',
199
+ `Goal: ${String(goal?.text ?? '').replace(/\s+/g, ' ').slice(0, 500) || '(unknown)'}`,
200
+ `Progress: ${done}/${reqs.length} requirements done`,
201
+ goal?.reviewSummary?.changed ? `What changed: ${String(goal.reviewSummary.changed).replace(/\s+/g, ' ').slice(0, 300)}` : null,
202
+ goal?.testResult ? `Tests: ${goal.testResult.ran ? `${goal.testResult.passed ?? 0} passed / ${goal.testResult.failed ?? 0} failed` : `not run (${goal.testResult.skippedReason ?? 'skipped'})`}` : null,
203
+ files.length ? `Changed files: ${files.join(', ')}` : null,
204
+ reports.length ? ['Recent worker reports:', ...reports].join('\n') : null,
205
+ 'Continue from files on disk and this summary. Do not assume hidden chat history.',
206
+ ].filter(Boolean).join('\n').slice(0, 4000);
207
+ }
208
+
209
+ export function estimateTokenOptimization({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null } = {}) {
210
+ const u = usage ?? {};
211
+ const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
212
+ const weighted = weightedTokens != null && Number.isFinite(Number(weightedTokens))
213
+ ? Number(weightedTokens)
214
+ : usageBudgetTokens(u, { cacheReadWeight });
215
+ const saved = Math.max(0, raw - weighted);
216
+ const percent = raw > 0 ? Math.round((saved / raw) * 100) : 0;
217
+ const techniques = [];
218
+ if ((u.cache_read_input_tokens ?? 0) > 0) techniques.push('cache-read weighting');
219
+ if (['compact', 'cold-handoff'].includes(sessionMode)) techniques.push(sessionMode === 'compact' ? 'compact handoff' : 'fresh handoff');
220
+ if (usedHandoff) techniques.push('AI handoff');
221
+ if (usedCompact) techniques.push('/compact');
222
+ if (usedClear) techniques.push('/clear');
223
+ if (agentModel) techniques.push(`model ${agentModel}`);
224
+ if (!percent && !techniques.length) return null;
225
+ return { rawTokens: raw, weightedTokens: weighted, savedTokens: saved, percent, techniques: [...new Set(techniques)] };
226
+ }
227
+
228
+ export function estimateFailureCauseMix({ usage = null, blockedKind = null, changedFiles = [], testResult = null, attentionHadDetail = true, taskCount = 1 } = {}) {
229
+ const causes = [
230
+ { id: 'session-cache', label: '巨大 session / cache read の肥大化', weight: 0 },
231
+ { id: 'verification-contract', label: '検証ゲートのケース分岐不足', weight: 0 },
232
+ { id: 'project-splitting', label: 'Project / タスク分割が粗い', weight: 0 },
233
+ { id: 'attention-explainability', label: 'Attention / Review の説明不足', weight: 0 },
234
+ { id: 'test-flake', label: 'テストフレーク・環境差分', weight: 0 },
235
+ ];
236
+ const add = (id, w) => { const c = causes.find((x) => x.id === id); if (c) c.weight += w; };
237
+ const fresh = (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0);
238
+ const cacheRead = usage?.cache_read_input_tokens ?? 0;
239
+ if (cacheRead > 500_000 && cacheRead > fresh * 5) add('session-cache', 45);
240
+ if (blockedKind === 'budget') add('session-cache', 35);
241
+ if (blockedKind === 'test') add('verification-contract', hasTestRelevantChanges(changedFiles) ? 25 : 55);
242
+ if (testResult?.failed > 0 && !hasTestRelevantChanges(changedFiles)) add('verification-contract', 35);
243
+ if ((changedFiles ?? []).length > 8 || taskCount > 3) add('project-splitting', 25);
244
+ if (!attentionHadDetail) add('attention-explainability', 20);
245
+ if (/did not report ready in time|fetch failed|bad port/i.test(String(testResult?.detail ?? ''))) add('test-flake', 30);
246
+ if (!causes.some((c) => c.weight > 0)) {
247
+ add('session-cache', 35); add('verification-contract', 30); add('project-splitting', 20); add('attention-explainability', 10); add('test-flake', 5);
248
+ }
249
+ const total = causes.reduce((s, c) => s + c.weight, 0) || 1;
250
+ return causes
251
+ .map((c) => ({ id: c.id, label: c.label, percent: Math.round((c.weight / total) * 100) }))
252
+ .filter((c) => c.percent > 0)
253
+ .sort((a, b) => b.percent - a.percent);
254
+ }
255
+
256
+ function primaryFailureCause({ usage = null, blockedKind = null, changedFiles = [], testResult = null, attentionHadDetail = true, taskCount = 1 } = {}) {
257
+ return estimateFailureCauseMix({ usage, blockedKind, changedFiles, testResult, attentionHadDetail, taskCount })[0]
258
+ ?? { id: 'unknown', label: '原因未特定', percent: 0 };
259
+ }
260
+
261
+ function failurePhase(kind) {
262
+ if (kind === 'budget' || kind === 'rate-limit') return 'worker-run';
263
+ if (kind === 'test' || kind === 'proof' || kind === 'verify') return 'manager-verification';
264
+ return 'execution';
265
+ }
266
+
267
+ function failureSummaryFor({ kind, goalText, testResult, budgetReason, proofMissing, verifyFail }) {
268
+ const needed = String(goalText ?? '').replace(/\s+/g, ' ').trim().slice(0, 120) || '依頼内容の完了';
269
+ if (kind === 'budget') {
270
+ return {
271
+ whatHappened: `必要だった作業「${needed}」は worker 実行中に token budget guardrail で止まりました。`,
272
+ yourCall: '次回は cold-handoff と狭い検査範囲で再開し、広い探索や巨大セッション再開を避けます。',
273
+ };
274
+ }
275
+ if (kind === 'rate-limit') {
276
+ return {
277
+ whatHappened: `必要だった作業「${needed}」は Claude の利用制限で一時停止しました。`,
278
+ yourCall: '再開可能時刻まで待ってから自動再開し、即時リトライで同じ制限を踏まないようにします。',
279
+ };
280
+ }
281
+ if (kind === 'test') {
282
+ const failed = Number(testResult?.failed ?? 0);
283
+ return {
284
+ whatHappened: `必要だった作業「${needed}」は実装後の Manager 検証まで進みましたが、テスト ${failed} 件で止まりました。`,
285
+ yourCall: '失敗ログを読ませて回帰を直し、関連テストから再実行してから全体確認に戻します。',
286
+ };
287
+ }
288
+ if (kind === 'proof') {
289
+ return {
290
+ whatHappened: `必要だった作業「${needed}」は UI 変更後の proof 撮影で止まりました。`,
291
+ yourCall: '起動・撮影条件を確認し、ユーザーが判断できるスクショまたは動画を取得してから Review に戻します。',
292
+ };
293
+ }
294
+ if (kind === 'verify' || verifyFail) {
295
+ return {
296
+ whatHappened: `必要だった作業「${needed}」は検証条件を満たせず Review に進めませんでした。`,
297
+ yourCall: '不足している proof / 検証結果をそろえ、判断できる状態にしてから再提出します。',
298
+ };
299
+ }
300
+ return {
301
+ whatHappened: budgetReason ? String(budgetReason).slice(0, 180) : `必要だった作業「${needed}」が途中で止まりました。`,
302
+ yourCall: '停止地点のログを読んで、同じ失敗を避ける最小ステップで再開します。',
303
+ };
304
+ }
305
+
306
+ export function buildFailurePostmortem({
307
+ goal = null,
308
+ tasks = [],
309
+ kind = 'unknown',
310
+ reason = '',
311
+ usage = null,
312
+ changedFiles = [],
313
+ testResult = null,
314
+ attentionHadDetail = true,
315
+ previousEntries = [],
316
+ } = {}) {
317
+ const taskList = (tasks ?? []).filter(Boolean);
318
+ const files = [...new Set([...(changedFiles ?? []), ...taskList.flatMap((t) => t.changedFiles ?? [])])]
319
+ .filter(Boolean);
320
+ const cause = primaryFailureCause({
321
+ usage,
322
+ blockedKind: kind,
323
+ changedFiles: files,
324
+ testResult,
325
+ attentionHadDetail,
326
+ taskCount: taskList.filter((t) => !t.reply).length || 1,
327
+ });
328
+ const recurrenceKey = `${kind}:${cause.id}:${hasTestRelevantChanges(files) ? 'code' : 'non-code'}`;
329
+ const recurrenceCount = (previousEntries ?? []).filter((e) => e?.recurrenceKey === recurrenceKey).length + 1;
330
+ const summary = failureSummaryFor({
331
+ kind,
332
+ goalText: goal?.text,
333
+ testResult,
334
+ budgetReason: reason,
335
+ proofMissing: kind === 'proof',
336
+ verifyFail: kind === 'verify',
337
+ });
338
+ const preventionByCause = {
339
+ 'session-cache': ['cold-handoff', 'compact summary', 'narrow file reads', 'no broad repository scan before retry'],
340
+ 'verification-contract': ['pass failing test output into auto-feedback', 'run narrow related tests first', 'skip unrelated full-suite block for docs/report-only work'],
341
+ 'project-splitting': ['split into a narrower internal workstream', 'finish one small patch before expanding scope'],
342
+ 'attention-explainability': ['show what was needed, where it stopped, and the next action in the first view'],
343
+ 'test-flake': ['retry deterministic setup once', 'preserve environment logs', 'separate environment failure from product regression'],
344
+ };
345
+ const nextPolicyByCause = {
346
+ 'session-cache': 'Start the next attempt from a fresh handoff instead of resuming the large session.',
347
+ 'verification-contract': 'Give the worker the exact failing output and require a narrow fix plus rerun.',
348
+ 'project-splitting': 'Route follow-up work into a smaller workstream before broad implementation.',
349
+ 'attention-explainability': 'Summarize the failure in user language before asking for a decision.',
350
+ 'test-flake': 'Retry the deterministic setup and keep raw logs for human review if it repeats.',
351
+ };
352
+ return {
353
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
354
+ kind,
355
+ phase: failurePhase(kind),
356
+ recurrenceKey,
357
+ recurrenceCount,
358
+ rootCauseId: cause.id,
359
+ rootCause: cause.label,
360
+ confidence: cause.percent,
361
+ whatHappened: summary.whatHappened,
362
+ yourCall: summary.yourCall,
363
+ preventionAdded: preventionByCause[cause.id] ?? ['record failure details', 'retry with narrower scope'],
364
+ nextPolicy: nextPolicyByCause[cause.id] ?? 'Retry from the recorded failure details with narrower scope.',
365
+ evidence: {
366
+ reason: String(reason ?? '').slice(0, 1000),
367
+ failedTests: testResult?.failed ?? null,
368
+ changedFiles: files.slice(0, 20),
369
+ usage,
370
+ },
371
+ createdAt: new Date().toISOString(),
372
+ };
373
+ }
374
+
375
+ export function appendFailureMemory(goal, entry, limit = 12) {
376
+ const entries = [...(goal?.failureMemory ?? []), entry].filter(Boolean).slice(-limit);
377
+ return entries;
378
+ }
379
+
380
+ export function latestFailurePolicy(goalOrEntries) {
381
+ const entries = Array.isArray(goalOrEntries) ? goalOrEntries : (goalOrEntries?.failureMemory ?? []);
382
+ const last = entries?.[entries.length - 1];
383
+ if (!last) return null;
384
+ return {
385
+ recurrenceKey: last.recurrenceKey,
386
+ text: [
387
+ `Previous failure memory: ${last.whatHappened}`,
388
+ `Root cause: ${last.rootCause} (${last.confidence}% confidence).`,
389
+ `Prevention now required: ${(last.preventionAdded ?? []).join(', ')}.`,
390
+ `Next policy: ${last.nextPolicy}`,
391
+ ].join('\n'),
392
+ };
393
+ }
394
+
395
+ // Parse one line of `claude -p --output-format stream-json --verbose` output
396
+ // into displayable events. Returns an array (a single assistant message can
397
+ // carry several tool calls); empty array when the line has nothing to show.
398
+ export function parseStreamEvents(line) {
399
+ let ev;
400
+ try { ev = JSON.parse(line); } catch { return []; }
401
+ if (ev.type === 'system' && ev.subtype === 'init' && ev.session_id) {
402
+ return [{ kind: 'session', id: String(ev.session_id) }];
403
+ }
404
+ if (ev.type === 'result') {
405
+ return [{ kind: 'result', text: String(ev.result ?? '').trim(), usage: parseResultUsage(ev) }];
406
+ }
407
+ if (ev.type !== 'assistant') return [];
408
+ const out = [];
409
+ for (const p of ev.message?.content ?? []) {
410
+ if (p.type === 'tool_use') {
411
+ const i = p.input ?? {};
412
+ // TodoWrite carries the worker's live to-do list; surface it as a
413
+ // dedicated event (the UI renders it as a checklist, not an activity line).
414
+ if (p.name === 'TodoWrite') {
415
+ out.push({ kind: 'todos', todos: Array.isArray(i.todos) ? i.todos : [] });
416
+ continue;
417
+ }
418
+ const arg = i.file_path ?? i.path ?? i.pattern ?? i.command ?? i.url ?? i.query ?? '';
419
+ out.push({ kind: 'activity', text: `${p.name} ${String(arg)}`.trim().slice(0, 140) });
420
+ } else if (p.type === 'text' && p.text?.trim()) {
421
+ out.push({ kind: 'activity', text: `✎ ${p.text.trim().replace(/\s+/g, ' ').slice(0, 110)}` });
422
+ }
423
+ }
424
+ return out;
425
+ }
426
+
427
+ // Parse the planner's reply into { entry, tasks }. Accepts either a JSON
428
+ // object {entry, tasks:[...]} or a bare JSON array anywhere in the text;
429
+ // falls back to a single task made from the raw request. Each task may carry
430
+ // a passCondition (used for automatic proof verification).
431
+ export function parsePlan(text, fallbackTitle) {
432
+ const str = String(text ?? '');
433
+ const normalize = (arr) => arr
434
+ .map((x) => typeof x === 'string'
435
+ ? { title: x, detail: '', passCondition: null }
436
+ : {
437
+ title: String(x?.title ?? x?.task ?? '').trim(),
438
+ detail: String(x?.detail ?? x?.description ?? '').trim(),
439
+ passCondition: x?.passCondition ? String(x.passCondition).trim() : null,
440
+ })
441
+ .filter((t) => t.title)
442
+ .slice(0, 6); // PRD §3: 1〜6 tasks (matches the planner prompt's cap)
443
+
444
+ const obj = str.match(/\{[\s\S]*\}/);
445
+ if (obj) {
446
+ try {
447
+ const o = JSON.parse(obj[0]);
448
+ if (Array.isArray(o?.tasks)) {
449
+ const tasks = normalize(o.tasks);
450
+ if (tasks.length) return { entry: o.entry ? String(o.entry).trim() : null, tasks };
451
+ }
452
+ } catch { /* fall through */ }
453
+ }
454
+ const arr = str.match(/\[[\s\S]*\]/);
455
+ if (arr) {
456
+ try {
457
+ const a = JSON.parse(arr[0]);
458
+ if (Array.isArray(a)) {
459
+ const tasks = normalize(a);
460
+ if (tasks.length) return { entry: null, tasks };
461
+ }
462
+ } catch { /* fall through */ }
463
+ }
464
+ const title = String(fallbackTitle ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
465
+ return { entry: null, tasks: [{ title, detail: '', passCondition: null }] };
466
+ }
467
+
468
+ // ---- planner decomposition = USER-TOPIC granularity ------------------------
469
+ // Masa (dogfood): 「プログラム的なタスクに分解しすぎ。ユーザー体験としての
470
+ // タスクを積んでるのに…『音声入力』と『ビジュアル作成』の2つに分けるのはいい。
471
+ // でも『PRDを作って』みたいなブレイクダウンはいらない」。So the planner splits
472
+ // ONLY on distinct user-facing deliverables, never on engineering PHASES.
473
+ //
474
+ // Titles that plainly name an engineering PHASE (a step in building something)
475
+ // rather than a user-facing outcome. Deliberately NARROW — only unmistakable
476
+ // build-step language — so a genuine deliverable is never mistaken for a phase.
477
+ const ENGINEERING_PHASE_RE = /(^|[^A-Za-z])PRD([^A-Za-z]|$)|仕様書|要件定義|設計(書|する|を|の)|テスト(を|の|コード)?(書|追加|作成|実装)|(write|add|unit|integration|e2e)[\s-]*tests?|テストを書|リファクタ|refactor|パーサ[ー]?(を|の)?(追加|実装|書|作)|型定義|scaffold|ボイラープレート|boilerplate/i;
478
+
479
+ export function isEngineeringPhaseTitle(title) {
480
+ return ENGINEERING_PHASE_RE.test(String(title ?? ''));
481
+ }
482
+
483
+ // Reduce a planner's task list to USER-TOPIC granularity — the deterministic
484
+ // backstop for when the LLM over-decomposes despite the prompt. Two moves, both
485
+ // conservative so a distinct ask is never silently dropped:
486
+ // 1) drop tasks whose titles are plainly engineering phases — but ONLY when at
487
+ // least one genuine user-facing task survives (never delete the whole plan).
488
+ // 2) clamp to `maxTasks` (default 4): a single coherent request should be 1
489
+ // topic; only genuinely separate asks split (realistically 1–3).
490
+ // When EVERY task looks like a phase, that's the signal the whole request is ONE
491
+ // topic that got shredded into build steps: collapse to a single task carrying
492
+ // the user's own goal text as the title, with the phase details merged so the
493
+ // worker still owns the full engineering breakdown internally.
494
+ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
495
+ const list = (tasks ?? []).filter((t) => t && String(t.title ?? '').trim());
496
+ if (list.length <= 1) return list.slice(0, maxTasks);
497
+ const real = list.filter((t) => !isEngineeringPhaseTitle(t.title));
498
+ if (!real.length) {
499
+ const title = String(goalText ?? list[0].title).replace(/\s+/g, ' ').trim().slice(0, 120);
500
+ const detail = list.map((t) => t.detail).filter(Boolean).join('\n');
501
+ const passCondition = list.find((t) => t.passCondition)?.passCondition ?? null;
502
+ return [{ title, detail, passCondition }];
503
+ }
504
+ return real.slice(0, maxTasks);
505
+ }
506
+
507
+ // ---- dedupe / merge overlapping goals --------------------------------------
508
+ // Masa (dogfood): 「被ったやつも自分でちゃんと理解してまとめてほしい」。When a
509
+ // new goal near-duplicates an existing active one, fold it in as a follow-up
510
+ // instead of piling up a redundant parallel goal.
511
+ //
512
+ // Normalize goal text for overlap comparison: lowercase, collapse whitespace
513
+ // (incl. full-width space), strip punctuation — so "PRDを作って!" and
514
+ // "prd を 作って" compare alike.
515
+ export function normalizeGoalText(text) {
516
+ return String(text ?? '')
517
+ .toLowerCase()
518
+ .replace(/[\s ]+/g, ' ')
519
+ .replace(/[、。,.,.!!??「」『』()()\[\]{}"'`~・…—\-_/\\:;]/g, '')
520
+ .replace(/\s+/g, ' ')
521
+ .trim();
522
+ }
523
+
524
+ // Similarity 0..1 between two goal texts. Combines word tokens with CJK
525
+ // bigrams (Japanese has no spaces, so word-splitting alone can't compare
526
+ // 日本語同士) into a Jaccard overlap; identical normalized text short-circuits
527
+ // to 1. Pure and symmetric.
528
+ export function goalTextSimilarity(a, b) {
529
+ const na = normalizeGoalText(a), nb = normalizeGoalText(b);
530
+ if (!na || !nb) return 0;
531
+ if (na === nb) return 1;
532
+ const toks = (s) => {
533
+ // Latin/alphanumeric WORDS (≥2 chars) + CJK BIGRAMS. Bigrams matter because
534
+ // Japanese has no spaces — splitting on ' ' alone would make the whole
535
+ // spaceless string one giant token and never compare 日本語同士.
536
+ const set = new Set((s.match(/[a-z0-9]{2,}/g) ?? []));
537
+ const cjk = s.replace(/[^぀-ヿ一-鿿]/g, '');
538
+ for (let i = 0; i < cjk.length - 1; i++) set.add(cjk.slice(i, i + 2));
539
+ return set;
540
+ };
541
+ const sa = toks(na), sb = toks(nb);
542
+ if (!sa.size || !sb.size) return 0;
543
+ let inter = 0;
544
+ for (const t of sa) if (sb.has(t)) inter++;
545
+ return inter / (sa.size + sb.size - inter);
546
+ }
547
+
548
+ // Find an existing goal a new goal's text substantially overlaps (near
549
+ // duplicate), so the caller can fold it in as a thread follow-up rather than
550
+ // create a redundant parallel goal. `existingGoals` is expected to be already
551
+ // filtered by the caller to real merge candidates (same project, active,
552
+ // session-bearing). Returns { goal, score } only on HIGH confidence
553
+ // (threshold 0.72); when unsure, null → the caller creates the goal, so a
554
+ // genuinely distinct ask is never silently swallowed. Very short texts
555
+ // (< 6 normalized chars) never match — too little signal to be sure.
556
+ export const GOAL_OVERLAP_THRESHOLD = 0.72;
557
+ export function findOverlappingGoal(text, existingGoals, { threshold = GOAL_OVERLAP_THRESHOLD } = {}) {
558
+ if (normalizeGoalText(text).length < 6) return null;
559
+ let best = null, bestScore = 0;
560
+ for (const g of existingGoals ?? []) {
561
+ if (normalizeGoalText(g?.text).length < 6) continue;
562
+ const score = goalTextSimilarity(text, g?.text);
563
+ if (score > bestScore) { bestScore = score; best = g; }
564
+ }
565
+ return best && bestScore >= threshold ? { goal: best, score: bestScore } : null;
566
+ }
567
+
568
+ // ---- which goal states allow EDIT / DELETE from the board ------------------
569
+ // Masa (dogfood): 「急に入ったやつ(ゴール)が編集したり削除したりできない。
570
+ // できるようにして」。Expanded beyond the original stacked/pending.
571
+ //
572
+ // EDIT (text / PR toggle) is allowed while a goal has not meaningfully started
573
+ // producing a reviewable result: stacked, pending, planning (being
574
+ // decomposed), needsInput (awaiting an answer), and a 'running' goal that is
575
+ // still QUEUED (decomposed but no task has actually picked up yet — startedAt
576
+ // unset). A goal whose worker is in flight (running + startedAt) or that has
577
+ // already produced a result (review / done / blocked / retesting …) is NOT
578
+ // editable — rewriting its text underneath a live worker or a produced PR would
579
+ // be dishonest; use reply/dismiss for those.
580
+ export const EDITABLE_GOAL_STATUSES = ['stacked', 'sending', 'pending', 'planning', 'needsInput'];
581
+ export function canEditGoal(status, startedAt) {
582
+ if (EDITABLE_GOAL_STATUSES.includes(status)) return true;
583
+ if (status === 'running' && !startedAt) return true; // queued: decomposed, not yet started
584
+ return false;
585
+ }
586
+
587
+ // DELETE (cancel) is allowed up to and including an actively-running goal:
588
+ // stacked/pending/planning/needsInput/queued cancel with little/nothing to
589
+ // stop, and a truly-running goal is cancelled by SIGTERMing its worker child
590
+ // and dropping its queued tasks (server side). A goal already in review or
591
+ // finished (done) is NOT deleted here — that's the Ledger's Archive/Revert
592
+ // territory, and 'done' keeps its 409 as before.
593
+ export const DELETABLE_GOAL_STATUSES = ['stacked', 'sending', 'pending', 'planning', 'needsInput', 'running', 'partial', 'failed', 'interrupted', 'blocked'];
594
+ export function canDeleteGoal(status) {
595
+ return DELETABLE_GOAL_STATUSES.includes(status);
596
+ }
597
+
598
+ // Should the planner surface a clarifying question and park the goal in
599
+ // needsInput? Only on the FIRST plan. Once the user has answered once
600
+ // (goal.clarified, set by /answer), NEVER re-ask — otherwise a re-plan that
601
+ // still returns a question loops the already-answered card back onto the board
602
+ // (Masa: 「一度答えたのに再表示」). The answer is already folded into goal.text,
603
+ // so a re-plan decomposes normally. Structural guarantee: even if the planner
604
+ // LLM disobeys the prompt and returns a question again, this returns false.
605
+ export function shouldAskClarification(clarify, goal) {
606
+ return !!clarify && !goal?.clarified;
607
+ }
608
+
609
+ // A human-readable reason for a worker that ended WITHOUT emitting any result
610
+ // text — replaces the old bare "(no output)" that told the user nothing about
611
+ // WHY a task stopped (Masa: 「failedの原因が分からない」). timedOut = the run-time
612
+ // cap SIGTERMed it (a forced stop, not a code bug); exit 143/130 = SIGTERM/SIGINT
613
+ // (timeout or a cancel); any other non-zero = the CLI crashed/exited early.
614
+ export function workerExitReason(code, timedOut, timeoutMs = 10 * 60 * 1000) {
615
+ const mins = Math.max(1, Math.round(timeoutMs / 60000));
616
+ // No trailing sentence punctuation — the Attention card embeds this mid-sentence
617
+ // and appends its own terminator.
618
+ if (timedOut) return `作業が${mins}分の実行上限に達したため自動停止しました(変更は途中の可能性あり)/ Worker hit the ${mins}-minute run limit and was stopped before finishing (the change may be partial)`;
619
+ if (code === 143 || code === 130) return '作業が途中で中断されました(レポート未生成)/ The worker was interrupted before it finished, no report was produced';
620
+ if (code && code !== 0) return `作業ツールが途中で終了しました(exit ${code}、レポート未生成)/ The worker tool exited early (exit ${code}) and produced no report`;
621
+ return '(no output)';
622
+ }
623
+
624
+ // Compose the text saved as task.result from a worker run. When the worker
625
+ // emitted a real report, use it. When it did NOT (killed mid-run / crashed),
626
+ // fall back to a concrete reason AND — for a forced stop — append the LAST thing
627
+ // it was doing when it was cut off, so the Attention card shows both WHY it
628
+ // stopped and HOW FAR it got (Masa: 「どこまでやったか分かるように」).
629
+ export function workerResultText({ result = '', err = '', code = 0, timedOut = false, lastAct = '', timeoutMs } = {}) {
630
+ if (result && result.trim()) return result;
631
+ const stderr = String(err || '').trim().slice(0, 1000);
632
+ if (stderr) return stderr;
633
+ const reason = workerExitReason(code, timedOut, timeoutMs);
634
+ const tail = String(lastAct || '').trim().slice(0, 300);
635
+ return tail && isForcedStop(code, timedOut) ? `${reason}\n最後の作業 / last step: ${tail}` : reason;
636
+ }
637
+
638
+ // Was the worker forcibly STOPPED (timeout or SIGTERM) rather than failing on
639
+ // its own? Such a task is 'interrupted' (retryable, not a code failure) — never
640
+ // a bare 'failed', which reads as "the worker's code was wrong". Mirrors the
641
+ // budget-breach precedent (goal-guardrails: interrupted, human-visible).
642
+ export function isForcedStop(code, timedOut) {
643
+ return Boolean(timedOut) || code === 143 || code === 130;
644
+ }
645
+
646
+ // A task counts as complete for GOAL status when it cleanly finished
647
+ // (done/skipped) OR it was force-stopped (interrupted) but its change is STRONGLY
648
+ // verified — an independent passCondition verification PASSED (proof.verified &&
649
+ // proof.pass). A weak auto-snapshot (no .verified) never promotes an interrupted
650
+ // goal, so genuinely-incomplete work still lands in Attention for a human.
651
+ // (Proposal 3: a run-cap timeout that happened AFTER the change was proven good
652
+ // should reach Review, not sit in 'partial' looking like a failure.)
653
+ export function taskCountsAsComplete(task) {
654
+ if (['done', 'skipped'].includes(task?.status)) return true;
655
+ return task?.status === 'interrupted' && task?.proof?.verified === true && task?.proof?.pass === true;
656
+ }
657
+
658
+ // Should the chat stream auto-scroll to the bottom after a re-render?
659
+ // Only true while the user is already at (or within `threshold`px of) the
660
+ // bottom — otherwise a re-render triggered by an unrelated status update
661
+ // (SSE, task progress, …) would yank them away from log they're reading.
662
+ export function shouldAutoScroll(scrollTop, scrollHeight, clientHeight, threshold = 100) {
663
+ return scrollHeight - clientHeight - scrollTop <= threshold;
664
+ }
665
+
666
+ // Decide the scrollTop to apply *after* a re-render replaces #stream's HTML
667
+ // (task 93). `pre` is the element's geometry captured BEFORE the innerHTML
668
+ // swap; `newScrollHeight` is its height AFTER. If the user was near the bottom
669
+ // we follow the new bottom; otherwise we pin the exact prior scrollTop so
670
+ // reading old history is never yanked by an incoming SSE/refresh render.
671
+ export function nextStreamScrollTop(pre, newScrollHeight, threshold = 100) {
672
+ return shouldAutoScroll(pre.scrollTop, pre.scrollHeight, pre.clientHeight, threshold)
673
+ ? newScrollHeight
674
+ : pre.scrollTop;
675
+ }
676
+
677
+ // GET /api/state payload for tasks (task 49): the browser's live activity
678
+ // log (state.act) is otherwise only ever populated by streamed SSE 'act'
679
+ // events, so a page refresh mid-run would blank a running task's log until
680
+ // new lines arrive. Send the already-accumulated `activity` back for
681
+ // running tasks so the client can seed state.act from it; every other
682
+ // status never renders activity, so it's stripped to keep the payload
683
+ // small (activity is capped at 200 lines/task but there can be many tasks).
684
+ export function trimTaskActivityForState(tasks) {
685
+ // activity + todos are live-only: a running task keeps them so a UI reload
686
+ // restores the in-flight log/checklist; a finished task drops both.
687
+ return tasks.map((t) => t.status === 'running' ? t : (({ activity, todos, ...rest }) => rest)(t));
688
+ }
689
+
690
+ // PROOF.md for a goal PR: one section per verified task, GIF auto-plays.
691
+ export function buildGoalProofMd({ goalText, items }) {
692
+ return [
693
+ `# Proof / 動作確認`,
694
+ ``,
695
+ `Goal / ゴール: ${String(goalText ?? '').replace(/\s+/g, ' ').slice(0, 300)}`,
696
+ ``,
697
+ `Each task below was verified independently by Manager for AI in a headless browser (deterministic check, no LLM at verification time).`,
698
+ `以下の各タスクは Manager for AI が headless ブラウザで独立検証しました(検証はdeterministic・検証時にLLMは使いません)。`,
699
+ ``,
700
+ ...items.flatMap((it) => [
701
+ `## ${it.num} ${it.title}`,
702
+ ``,
703
+ `Pass condition / 成功条件: ${it.passCondition}`,
704
+ ``,
705
+ it.beforeShotName ? `Before / 変更前:\n\n![before](./${it.beforeShotName})\n` : '',
706
+ it.beforeShotName ? `After / 変更後:\n` : '',
707
+ it.gifName ? `![demo](./${it.gifName})\n` : '',
708
+ it.shotName ? `![proof](./${it.shotName})\n` : '',
709
+ it.videoName ? `Full-quality video / 高画質mp4: [${it.videoName}](./${it.videoName})\n` : '',
710
+ `Result / 結果: ${it.detail}`,
711
+ ``,
712
+ ]),
713
+ ].filter((l) => l !== '').join('\n');
714
+ }
715
+
716
+ // A finished task's changed files look like a UI edit: app/ (the Manager's
717
+ // own UI dir, or an equivalent frontend root) plus common frontend source
718
+ // extensions. Pure/deterministic — no LLM — so it can gate proof capture
719
+ // for EVERY UI-touching goal, not just ones that happened to get a
720
+ // passCondition (Masa dogfood: "見た目の調整" goals had no capture at all).
721
+ const UI_TOUCH_RE = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
722
+ export function isUiChange(changedFiles) {
723
+ return (changedFiles ?? []).some((f) => UI_TOUCH_RE.test(String(f ?? '')));
724
+ }
725
+
726
+ // Whether a finished UI-touching task/goal needs a zero-LLM proof capture
727
+ // (screenshot + short clip of the ephemeral branch instance): only when the
728
+ // project can actually be booted & screenshotted (canCapture), nothing has
729
+ // captured proof yet (hasProof), and the change really looks like UI
730
+ // (isUiChange). This is the ADDED path for goals with no explicit
731
+ // passCondition — the existing passCondition-based verify loop (runVerification
732
+ // in verify.mjs) still runs and still asserts pass/fail; this only decides
733
+ // whether to ALSO take a capture-only snapshot when that loop didn't run.
734
+ export function shouldCaptureProof({ changedFiles, hasProof, canCapture }) {
735
+ return Boolean(canCapture) && !hasProof && isUiChange(changedFiles);
736
+ }
737
+
738
+ const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
739
+ export function hasTestRelevantChanges(changedFiles) {
740
+ const files = (changedFiles ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
741
+ if (!files.length) return true;
742
+ return files.some((f) => !DOC_ONLY_RE.test(f));
743
+ }
744
+
745
+ // Whether to ALSO capture a BASELINE ("before") shot for a before/after
746
+ // comparison. Only worth attempting when the baseline is a genuinely
747
+ // different checkout of the code than the one just captured as "after" — a
748
+ // goal whose worktree fell back to the shared project dir (no isolated
749
+ // worktree available) would otherwise "capture" the SAME already-changed
750
+ // code twice and label it before/after, which is not a comparison, it's a
751
+ // fabrication. Best-effort beyond that: gated separately by whether the
752
+ // baseline dir can itself be snapshotted (canCaptureBaseline).
753
+ export function shouldCaptureBaseline({ baselineDir, afterDir, canCaptureBaseline }) {
754
+ if (!baselineDir || !afterDir || baselineDir === afterDir) return false;
755
+ return Boolean(canCaptureBaseline);
756
+ }
757
+
758
+ // Task 56: a project's review definition can carry a free-text `description`
759
+ // written in the Review settings panel (e.g. "GitHubにプルリクエストを出して
760
+ // ください"). Before this, that text only showed up next to the Review
761
+ // column and had no effect on the PR Manager for AI actually opens. Fold it
762
+ // into the PR body as its own section so the rule is visibly reflected in
763
+ // the goal's PR, not just displayed in the UI. Returns [] (nothing to
764
+ // splice in) when there is no description to show.
765
+ export function buildReviewRuleSection(description) {
766
+ const text = String(description ?? '').trim();
767
+ if (!text) return [];
768
+ return [
769
+ `## Review rule / レビュールール`,
770
+ ``,
771
+ text,
772
+ ``,
773
+ ];
774
+ }
775
+
776
+ // Stable reorder: items whose ids appear in `ids` follow that order (in the
777
+ // positions they occupied); everything else keeps its place.
778
+ export function orderByIds(arr, ids, getId = (x) => x.id) {
779
+ const want = ids.map(Number);
780
+ const targets = arr.filter((x) => want.includes(Number(getId(x))));
781
+ targets.sort((a, b) => want.indexOf(Number(getId(a))) - want.indexOf(Number(getId(b))));
782
+ let i = 0;
783
+ return arr.map((x) => (want.includes(Number(getId(x))) ? targets[i++] : x));
784
+ }
785
+
786
+ // Drag-reorder for the "To do" queue: only 'queued' tasks may change
787
+ // position. A task that has already been picked up (status 'running') must
788
+ // never be paused or reprioritized by a To-do reorder, so it's excluded from
789
+ // the reorder entirely and kept exactly where it was.
790
+ export function reorderQueue(waiting, ids) {
791
+ const queued = waiting.filter((t) => t.status === 'queued');
792
+ const rest = waiting.filter((t) => t.status !== 'queued');
793
+ return [...orderByIds(queued, ids), ...rest];
794
+ }
795
+
796
+ // Priority queue ordering: '高' runs before '中' before '低'. A task with no
797
+ // priority (or an unrecognized value) is treated as '中', matching the
798
+ // default new tasks are created with. Array.prototype.sort is a stable sort
799
+ // (guaranteed by the spec), so this never disturbs the relative order tasks
800
+ // already have within the same priority — that incoming order already
801
+ // reflects creation order (queues.waiting is appended to in creation order)
802
+ // and any drag reorder (reorderQueue above, which is itself order-preserving
803
+ // for everything it doesn't touch). Callers should feed this the array
804
+ // *after* applying a drag reorder, so a drag only ever re-ranks within a
805
+ // priority band rather than fighting it.
806
+ export const TASK_PRIORITIES = ['高', '中', '低'];
807
+ const PRIORITY_WEIGHT = { 高: 0, 中: 1, 低: 2 };
808
+ export function sortQueueByPriority(tasks) {
809
+ return tasks
810
+ .map((t, i) => ({ t, i }))
811
+ .sort((a, b) => (PRIORITY_WEIGHT[a.t.priority] ?? 1) - (PRIORITY_WEIGHT[b.t.priority] ?? 1) || a.i - b.i)
812
+ .map(({ t }) => t);
813
+ }
814
+
815
+ // ---- per-project bounded parallelism ---------------------------------------
816
+ // Masa: "same project でも conflict しないものは並列で実装してほしい" — each
817
+ // goal already runs in its own isolated git worktree (server.mjs goalWorkDir),
818
+ // so different goals' tasks can safely execute at the same time; only a
819
+ // SINGLE goal's OWN tasks must stay sequential (task 2 depends on task 1's
820
+ // work being on disk already). These two pure helpers decide "how many more
821
+ // tasks can I start right now" — server.mjs's pump() is the only caller that
822
+ // touches real child processes / the running Set.
823
+
824
+ // Clamp an env-provided concurrency limit to a sane range: at least 1 (a
825
+ // misconfigured 0/negative/non-numeric value must never wedge the queue
826
+ // entirely), at most `max` (a hard safety cap so a typo like "40" can't spawn
827
+ // forty `claude` processes). Falls back to `def` when unset/unparsable.
828
+ export function clampParallelLimit(value, { def = 2, min = 1, max = 4 } = {}) {
829
+ const n = Number(value);
830
+ if (!Number.isFinite(n) || n < 1) return def;
831
+ return Math.min(Math.max(Math.floor(n), min), max);
832
+ }
833
+
834
+ // Is there room to start another task, given how many are already running
835
+ // and the current limit (project-level or global-level — same shape either
836
+ // way)? A non-positive/NaN limit never permits starting anything.
837
+ export function canStartMore(runningCount, limit) {
838
+ return Number.isFinite(limit) && limit > 0 && (runningCount ?? 0) < limit;
839
+ }
840
+
841
+ // Pick up to `limit` tasks from the front of `waiting` that are safe to start
842
+ // RIGHT NOW, preserving same-goal ordering: a goal already represented in
843
+ // `runningGoalIds` (has an in-flight task) contributes nothing this round,
844
+ // and once this round has picked one task for a goal, no LATER task for that
845
+ // same goal is picked alongside it (its siblings were pushed after it, in
846
+ // plan order, so this is exactly "only the earliest not-yet-running task per
847
+ // goal is runnable"). Different goals interleaved in `waiting` are otherwise
848
+ // left in their existing order. Pure — callers own actually starting/tracking
849
+ // the tasks and the worktree/child-process side effects.
850
+ export function nextRunnableTasks(waiting, runningGoalIds, limit) {
851
+ if (!Number.isFinite(limit) || limit <= 0) return [];
852
+ const busy = new Set(runningGoalIds ?? []);
853
+ const claimedThisRound = new Set();
854
+ const picked = [];
855
+ for (const task of waiting ?? []) {
856
+ if (picked.length >= limit) break;
857
+ const gid = task?.goalId;
858
+ if (gid != null) {
859
+ if (busy.has(gid) || claimedThisRound.has(gid)) continue;
860
+ claimedThisRound.add(gid);
861
+ }
862
+ picked.push(task);
863
+ }
864
+ return picked;
865
+ }
866
+
867
+ // Drag-and-drop insertion point: given the hovered row's bounding rect
868
+ // ({top, bottom}, as from Element.getBoundingClientRect()) and the pointer's
869
+ // clientY, decide whether the drop belongs above that row (top half) or
870
+ // below it (bottom half). Drives both the insertion-line indicator shown
871
+ // while dragging and reorderByDrop below, so what the user sees lines up
872
+ // with where the item actually lands.
873
+ export function dndDropsAfter(rect, clientY) {
874
+ const mid = rect.top + (rect.bottom - rect.top) / 2;
875
+ return clientY >= mid;
876
+ }
877
+
878
+ // Recompute the ordered id list for a drag-drop, placing `draggedId` just
879
+ // before/after `targetId` (rather than always immediately before it, as a
880
+ // naive splice would) so the reordered list matches the half of `targetId`
881
+ // the pointer was over when it dropped.
882
+ export function reorderByDrop(ids, draggedId, targetId, after) {
883
+ if (draggedId === targetId) return ids.slice();
884
+ const arr = ids.filter((id) => id !== draggedId);
885
+ const idx = arr.indexOf(targetId);
886
+ if (idx === -1) return ids.slice();
887
+ arr.splice(idx + (after ? 1 : 0), 0, draggedId);
888
+ return arr;
889
+ }
890
+
891
+ // Bucket a goal's status into the four states shown on a Tasks-panel group
892
+ // header: queued tasks haven't started (todo), planning/running is active
893
+ // work (doing), an open PR awaits a human (review), everything else finished
894
+ // (done/partial/failed/interrupted all read as "done" — the failure detail
895
+ // is already visible on the task rows underneath).
896
+ //
897
+ // A goal moves to 'running' as soon as planning finishes, but its tasks all
898
+ // start out 'queued' — nothing is actually executing yet. goal.startedAt only
899
+ // gets set once the first non-reply task picks up (server.mjs), so a
900
+ // 'running' goal without startedAt is still queued from the user's point of
901
+ // view; only once startedAt is set has work actually begun (doing).
902
+ export function goalGroupStatus(goalStatus, startedAt) {
903
+ if (goalStatus === 'stacked' || goalStatus === 'sending') return 'todo';
904
+ if (goalStatus === 'running' && !startedAt) return 'todo';
905
+ if (goalStatus === 'planning' || goalStatus === 'running') return 'doing';
906
+ if (goalStatus === 'review') return 'review';
907
+ if (goalStatus === 'retesting') return 'doing'; // Ledger Dismiss → engine re-testing ×3 (v45 §5.3)
908
+ if (goalStatus === 'blocked') return 'attn'; // §4E: blocked is Attention, not Done
909
+ if (goalStatus === 'needsInput') return 'todo'; // smart intake: awaiting an answer
910
+ return 'done'; // includes 'reverted' (terminal)
911
+ }
912
+
913
+ // Label + CSS class for the small status chip shown next to a goal in the
914
+ // goal list and goal detail thread — mirrored in app/index.html's GOAL_CHIP
915
+ // since that inline script can't import this module.
916
+ //
917
+ // `columns` (task 44: a project's custom workflow column list, as saved by
918
+ // validateWorkflowColumns) lets the chip text follow a renamed stage — e.g.
919
+ // once a project renames its "review" column to "審査待ち", a goal sitting
920
+ // in review shows that name here too, not just in the Tasks-panel section
921
+ // header. Omitting `columns` (existing callers, and any project that never
922
+ // customized) reproduces the original hardcoded English labels exactly, so
923
+ // this is a pure additive refactor. A goal that failed/partially failed
924
+ // always reads as FAIL regardless of how the "done" column got renamed —
925
+ // that distinction lives on the task rows underneath and must stay visible,
926
+ // not get relabeled into whatever "done" is called today.
927
+ //
928
+ // `startedAt` (goal.startedAt, set once the first non-reply task actually
929
+ // starts running — see goalGroupStatus above) disambiguates 'running': a
930
+ // goal that just finished planning has every task still 'queued', so it
931
+ // reads as QUEUED until startedAt is set, then flips to DOING.
932
+ export function goalChip(goalStatus, columns, startedAt) {
933
+ if (goalStatus === 'stacked' || goalStatus === 'sending') return [pickColumnLabel(columns, 'todo', 'QUEUED'), ''];
934
+ if (goalStatus === 'running' && !startedAt) return [pickColumnLabel(columns, 'todo', 'QUEUED'), ''];
935
+ if (goalStatus === 'planning' || goalStatus === 'running') return [pickColumnLabel(columns, 'doing', 'DOING'), 'doing'];
936
+ if (goalStatus === 'review') return [pickColumnLabel(columns, 'review', 'REVIEW'), 'review'];
937
+ if (goalStatus === 'done') return [pickColumnLabel(columns, 'done', 'DONE'), ''];
938
+ if (goalStatus === 'retesting') return ['RE-TESTING', 'doing']; // Ledger Dismiss → retest ×3 (v45 §5.3)
939
+ if (goalStatus === 'reverted') return ['REVERTED', '']; // Ledger Revert: terminal, distinct from archived
940
+ if (goalStatus === 'blocked') return ['BLOCKED', 'blocked']; // §4E: distinct state (reason + reverify), not FAIL/Done
941
+ if (goalStatus === 'needsInput') return ['確認待ち', 'blocked']; // smart intake: awaiting an answer
942
+ return ['FAIL', ''];
943
+ }
944
+
945
+ // Group a flat task list under their parent goal for the Tasks sidebar, so
946
+ // a goal like "この体験を実現したい" shows as one collapsible unit instead of
947
+ // its tasks scattered flat in the list. Each group carries a done/total
948
+ // count (e.g. "2/5") and a status bucket derived from the goal itself, so
949
+ // the caller doesn't need to re-derive goal state to build the header. Tasks
950
+ // whose goal isn't in `goals` (deleted goal, or filtered out by the caller)
951
+ // come back separately as orphanTasks rather than being dropped.
952
+ export function groupTasksByGoal(tasks, goals) {
953
+ const groups = goals.map((g) => {
954
+ const gtasks = tasks.filter((t) => t.goalId === g.id);
955
+ return {
956
+ goal: g,
957
+ tasks: gtasks,
958
+ done: gtasks.filter((t) => t.status === 'done').length,
959
+ total: gtasks.length,
960
+ status: goalGroupStatus(g.status, g.startedAt),
961
+ };
962
+ });
963
+ const orphanTasks = tasks.filter((t) => !goals.some((g) => g.id === t.goalId));
964
+ return { groups, orphanTasks };
965
+ }
966
+
967
+ // Group an already-ordered task list under their goals, preserving the order
968
+ // in which each goal first appears in `tasks` (so the section's own sort —
969
+ // queue order for To do, num-desc for Done — is respected across groups and
970
+ // within them). Tasks whose goal isn't in `goals` collect into a trailing
971
+ // group with goal:null. Used to render per-goal headings in each Kanban
972
+ // column. Returns [{ goal, tasks }] with no empty groups.
973
+ export function groupTasksByGoalOrdered(tasks, goals) {
974
+ const byId = new Map(goals.map((g) => [g.id, g]));
975
+ const order = [];
976
+ const map = new Map();
977
+ for (const t of tasks) {
978
+ const key = byId.has(t.goalId) ? t.goalId : null;
979
+ if (!map.has(key)) { map.set(key, []); order.push(key); }
980
+ map.get(key).push(t);
981
+ }
982
+ return order.map((key) => ({ goal: key == null ? null : byId.get(key), tasks: map.get(key) }));
983
+ }
984
+
985
+ // A goal's review number must stay stable while sibling reviews get
986
+ // Approved and drop out of the list — an index-based "R1/R2/R3" reflows
987
+ // every time one is removed (task 118: "R2をApproveした後にまた別のがR2に
988
+ // なる"). Use the goal's own original TODO number instead: the lowest
989
+ // task.num among the tasks that belong to it (its first-decomposed task).
990
+ export function goalReviewNumber({ goal, tasks } = {}) {
991
+ if (goal == null) return null;
992
+ const nums = (tasks ?? [])
993
+ .filter((t) => t.goalId === goal.id)
994
+ .map((t) => t.num)
995
+ .filter((n) => typeof n === 'number' && Number.isFinite(n));
996
+ return nums.length ? Math.min(...nums) : null;
997
+ }
998
+
999
+ // Review digest (task 227): one row per review-pending goal, collapsed to a
1000
+ // single scannable line — check text, proof, test result, PR link — so a
1001
+ // reviewer can approve/reject without opening each goal individually. Goals
1002
+ // that share the same PR (a common goal-decomposed-into-several-tasks case)
1003
+ // collapse into one row instead of showing the same PR link twice; goals with
1004
+ // no PR never dedup against each other since there's nothing to key on.
1005
+ // Mirrored in app/index.html since the inline browser script can't import
1006
+ // this module.
1007
+ export function buildReviewDigest({ goals, tasks } = {}) {
1008
+ const rows = [];
1009
+ const byPr = new Map();
1010
+ for (const g of goals ?? []) {
1011
+ if (g?.status !== 'review') continue;
1012
+ const goalTasks = (tasks ?? []).filter((t) => t.goalId === g.id);
1013
+ const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 66);
1014
+ const plan = isPlanReview(g);
1015
+ const row = {
1016
+ goalIds: [g.id],
1017
+ reviewNumber: goalReviewNumber({ goal: g, tasks }),
1018
+ checkLine: plan ? `PLAN — approve to execute: ${checkLine}`.slice(0, 90) : checkLine,
1019
+ pr: g.pr ?? null,
1020
+ testResult: g.testResult ?? null,
1021
+ proof: goalTasks.find((t) => t.proof)?.proof ?? null,
1022
+ plan,
1023
+ planText: plan ? (g.planText ?? '') : null,
1024
+ };
1025
+ if (row.pr && byPr.has(row.pr)) {
1026
+ const existing = byPr.get(row.pr);
1027
+ existing.goalIds.push(g.id);
1028
+ if (row.reviewNumber != null && (existing.reviewNumber == null || row.reviewNumber < existing.reviewNumber)) {
1029
+ existing.reviewNumber = row.reviewNumber;
1030
+ }
1031
+ if (existing.proof == null) existing.proof = row.proof;
1032
+ if (existing.testResult == null) existing.testResult = row.testResult;
1033
+ continue;
1034
+ }
1035
+ if (row.pr) byPr.set(row.pr, row);
1036
+ rows.push(row);
1037
+ }
1038
+ return rows;
1039
+ }
1040
+
1041
+ // Does this goal ask for a pull request?
1042
+ export function wantsPullRequest(text) {
1043
+ return /プルリク|pull\s*request|(^|[^A-Za-z])PR([^A-Za-z]|$)/i.test(String(text ?? ''));
1044
+ }
1045
+
1046
+ // Task 46: the "Deliverable" toggle (pr | none | auto) on task creation and
1047
+ // editing. An explicit 'pr'/'none' always wins over the text heuristic, so
1048
+ // a user's choice survives editing the goal's text afterwards. `projectDefault`
1049
+ // (task 45: a project's reviewDefinition.defaultWantsPR, edited from the same
1050
+ // settings panel as the review definition) is only consulted for 'auto' when
1051
+ // the text itself gives no signal either way — an explicit "PRを作って" in the
1052
+ // text still wins even if the project's default is off, matching the
1053
+ // pre-task-45 behavior that new callers who omit this argument keep exactly.
1054
+ export function resolveWantsPR(pr, text, projectDefault = false) {
1055
+ return pr === 'pr' ? true : pr === 'none' ? false : (wantsPullRequest(text) || !!projectDefault);
1056
+ }
1057
+
1058
+ // Task 90: which entry point created a goal (Web chat / MCP / future API),
1059
+ // shown as a small badge next to the goal. Only the known values are
1060
+ // trusted; anything else (or a missing field, e.g. goals created before this
1061
+ // field existed) falls back to 'web' since that's the original and still
1062
+ // most common path. `sourceRef` is a reserved slot for a future Notion/Linear
1063
+ // ticket id or URL — always null today, populated once that integration exists.
1064
+ export const GOAL_SOURCES = ['web', 'mcp', 'api'];
1065
+ export function resolveGoalSource(input) {
1066
+ return GOAL_SOURCES.includes(input) ? input : 'web';
1067
+ }
1068
+
1069
+ // A project's review definition (task 45): configurable conditions that
1070
+ // decide whether a finished goal needs a human's review (status 'review')
1071
+ // or ships straight to 'done'/'partial'. requirePR reproduces the original,
1072
+ // hardcoded behavior — a goal that wants a PR always needs review, even if
1073
+ // PR creation itself failed (goal.prError carries the reason). Toggling it
1074
+ // off lets a project skip review entirely for PR-less workflows.
1075
+ // requireVerifyPass additionally forces review unless every task in the
1076
+ // goal has an independent headless-browser verification that passed
1077
+ // (task.proof.pass === true) — a task with no passCondition at all (so
1078
+ // proof stays null) does not satisfy it either, since "verify結果必須" means
1079
+ // a verify result is mandatory, not merely allowed to be absent.
1080
+ // `description` is free text explaining, to a human looking at the board,
1081
+ // what this project's review step actually checks — it has no effect on
1082
+ // nextGoalStatus() below, it just gets displayed next to the Review column.
1083
+ // `defaultWantsPR` is the fallback used by resolveWantsPR() above when a new
1084
+ // or edited goal leaves the Deliverable toggle on 'auto' and its text gives
1085
+ // no explicit signal — it decides whether *this project* opens PRs by
1086
+ // default, separately from whether a PR-wanting goal must go through review.
1087
+ // `language` is the output language for the Manager's review-facing text
1088
+ // (e.g. summarizeForReview's plain-language headline) — 'ja' or 'en' only.
1089
+ // `prSections` toggles which sections createGoalPR includes in the PR body;
1090
+ // `reviewCard` toggles what the review checklist card shows for each task.
1091
+ export const DEFAULT_REVIEW_DEFINITION = {
1092
+ requirePR: true,
1093
+ requireVerifyPass: false,
1094
+ description: '',
1095
+ defaultWantsPR: false,
1096
+ language: 'ja',
1097
+ prSections: { summary: true, whatChanged: true, howToReview: true, tests: true, screenshots: true, risk: true },
1098
+ reviewCard: { screenshots: true, beforeAfter: false },
1099
+ };
1100
+
1101
+ // Normalize a nested boolean-flag object (prSections/reviewCard) key by key,
1102
+ // so a partial payload — or one missing the whole sub-object — can't
1103
+ // silently disable flags it never mentioned; each key falls back to its own
1104
+ // default independently rather than the sub-object being replaced wholesale.
1105
+ function normalizeBoolMap(input, defaults) {
1106
+ const out = {};
1107
+ for (const key of Object.keys(defaults)) {
1108
+ out[key] = (input && typeof input === 'object' && !Array.isArray(input) && input[key] !== undefined)
1109
+ ? !!input[key] : defaults[key];
1110
+ }
1111
+ return out;
1112
+ }
1113
+
1114
+ // Validate + normalize a project's review definition (from the settings
1115
+ // panel's Save button). Missing fields fall back to the default rather than
1116
+ // `false`, so a partial payload can't silently disable an existing
1117
+ // requirement.
1118
+ export function validateReviewDefinition(input) {
1119
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'review definition object required' };
1120
+ return {
1121
+ ok: true,
1122
+ definition: {
1123
+ requirePR: input.requirePR !== undefined ? !!input.requirePR : DEFAULT_REVIEW_DEFINITION.requirePR,
1124
+ requireVerifyPass: input.requireVerifyPass !== undefined ? !!input.requireVerifyPass : DEFAULT_REVIEW_DEFINITION.requireVerifyPass,
1125
+ description: input.description !== undefined ? String(input.description).slice(0, 500) : DEFAULT_REVIEW_DEFINITION.description,
1126
+ defaultWantsPR: input.defaultWantsPR !== undefined ? !!input.defaultWantsPR : DEFAULT_REVIEW_DEFINITION.defaultWantsPR,
1127
+ language: (input.language === 'ja' || input.language === 'en') ? input.language : DEFAULT_REVIEW_DEFINITION.language,
1128
+ prSections: normalizeBoolMap(input.prSections, DEFAULT_REVIEW_DEFINITION.prSections),
1129
+ reviewCard: normalizeBoolMap(input.reviewCard, DEFAULT_REVIEW_DEFINITION.reviewCard),
1130
+ },
1131
+ };
1132
+ }
1133
+
1134
+ // What should goal.status become once all its tasks have finished? Driven
1135
+ // by the project's review definition above — a completed-but-unreviewed
1136
+ // goal must never read as "done", or a required review step silently gets
1137
+ // skipped.
1138
+ // Every finished goal lands in Review for a human check first — nothing reaches
1139
+ // Done unchecked. (Bug: previously only PR-wanting goals or verify-failures were
1140
+ // gated, so with defaultWantsPR:false the common non-PR goal slipped straight to
1141
+ // Done without review. Masa: 本来すべての to do が review に来るべき.) A human
1142
+ // Approve in the Manager (advanceReviewGoal/approveGoal) is what moves it to Done;
1143
+ // reviewToDoneStatus still auto-closes from the PR side. Only a goal that didn't
1144
+ // finish all its work stays 'partial' (its failed tasks show in Attention).
1145
+ export function nextGoalStatus({ allDone, wantsPR, allVerified = true, reviewDefinition = DEFAULT_REVIEW_DEFINITION }) {
1146
+ return allDone ? 'review' : 'partial';
1147
+ }
1148
+
1149
+ // チェックリストUIで要件が全件Approveされたら goal を review→done へ進める。
1150
+ // 以前は「PRありgoalはマージが唯一の完了トリガー」として弾いていたが、それだと
1151
+ // 承認しても done が永続化されず次のrefreshで review に戻る不具合になっていた
1152
+ // (しかもPR作成はしばしば失敗する)。approveGoal と同じくPRの有無で門前払いしない。
1153
+ // review→done はGitHub側からも自動で閉じる(syncGoalMerges/isPrApproved)——これは
1154
+ // 人がアプリ側から閉じる経路。
1155
+ export function advanceReviewGoal(goal) {
1156
+ if (goal.status !== 'review') return { ok: false, error: 'goal is not in review' };
1157
+ return { ok: true, status: 'done' };
1158
+ }
1159
+
1160
+ // advanceReviewGoal の逆操作(『元に戻す』ボタン)。対称に揃え、PRの有無で弾かない
1161
+ // ——どの done ゴールも review へ戻せる。
1162
+ export function revertReviewGoal(goal) {
1163
+ if (goal.status !== 'done') return { ok: false, error: 'goal is not done' };
1164
+ return { ok: true, status: 'review' };
1165
+ }
1166
+
1167
+ // レビュー詳細パネル(task 70)のApprove/Dismissボタンの判定。advanceReviewGoal(task 62,
1168
+ // チェックリスト全件Approveの自動進行)とは別に、人間がレビュー画面から単発で下す判断
1169
+ // を扱う——PRの有無を問わず、レビュー中のgoalだけが対象。
1170
+ // Approveはそのままdoneへ。goalGroupStatus/goalChipのdone分岐(review以外は全部doneと
1171
+ // 読む)と揃えているので、doneに寄せて問題ない。
1172
+ export function approveGoal({ goalStatus }) {
1173
+ if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1174
+ return { ok: true, status: 'done' };
1175
+ }
1176
+
1177
+ // Dismiss(差し戻し)は 'running' に戻す——goalGroupStatus/goalChipは 'running' を
1178
+ // startedAt済みなら'doing'バケットに読むので、これでレビュー中断→作業中の表示に戻る。
1179
+ export function dismissGoal({ goalStatus }) {
1180
+ if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1181
+ return { ok: true, status: 'running' };
1182
+ }
1183
+
1184
+ // ---- MODE (Auto / Plan) ----------------------------------------------------
1185
+ // A goal runs in one of two modes. 'auto' = the default implement→verify→review
1186
+ // flow (worker edits files with acceptEdits). 'plan' = the worker runs under
1187
+ // Claude Code's `--permission-mode plan` and produces a PLAN only, making NO
1188
+ // file edits; the plan lands in Review as "PLAN — approve to execute".
1189
+ export const GOAL_MODES = ['auto', 'plan'];
1190
+
1191
+ // Map a goal/task mode to the Claude Code CLI `--permission-mode` value.
1192
+ // Unknown / unset → 'acceptEdits' (unchanged default behavior). Only 'plan'
1193
+ // switches the worker into propose-a-plan-first mode.
1194
+ export function permissionModeFor(mode) {
1195
+ return mode === 'plan' ? 'plan' : 'acceptEdits';
1196
+ }
1197
+
1198
+ // A plan-review is a goal sitting in Review whose deliverable is a PLAN awaiting
1199
+ // human approval (no PR, no proof gate — there are no changes to prove). This
1200
+ // distinguishes it from a normal review so Approve re-queues to EXECUTE rather
1201
+ // than filing it to Done.
1202
+ export function isPlanReview(goal) {
1203
+ return !!goal && goal.status === 'review' && goal.planPhase === 'awaiting-approval';
1204
+ }
1205
+
1206
+ // Approving a plan-review does NOT file it to Done — it re-queues the SAME goal
1207
+ // to EXECUTE the approved plan: flip mode to 'auto' (acceptEdits), mark
1208
+ // planPhase 'executing', and run the normal implement→verify→review flow. The
1209
+ // caller re-queues the goal's tasks and injects goal.planText as context.
1210
+ export function nextAfterPlanApprove(goal) {
1211
+ if (!isPlanReview(goal)) return { ok: false, error: 'goal is not a plan awaiting approval' };
1212
+ return { ok: true, mode: 'auto', planPhase: 'executing', status: 'running' };
1213
+ }
1214
+
1215
+ // Interpret a `gh pr view <url> --json state,mergedAt` result. GitHub's PR
1216
+ // state is MERGED once merged (mergedAt is also set), but a fork/rebase-merge
1217
+ // can in principle carry a stale state while mergedAt is already populated,
1218
+ // so check both.
1219
+ export function isPrMerged({ state, mergedAt } = {}) {
1220
+ return state === 'MERGED' || !!mergedAt;
1221
+ }
1222
+
1223
+ // Interpret the same `gh pr view` result's reviewDecision field
1224
+ // (APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / null).
1225
+ export function isPrApproved({ reviewDecision } = {}) {
1226
+ return reviewDecision === 'APPROVED';
1227
+ }
1228
+
1229
+ // A 'review' goal whose PR merged OR was approved is done; every other goal
1230
+ // status (and a still-open, unapproved PR) passes through unchanged — this
1231
+ // never regresses a goal out of 'review' on its own.
1232
+ export function reviewToDoneStatus({ goalStatus, merged }) {
1233
+ // Only an actual MERGE closes a review (= the change shipped). A GitHub PR
1234
+ // *approval* must NOT auto-Done on reload — you review IN the Manager, so an
1235
+ // external approval silently clearing your Review was the "勝手にdone" surprise.
1236
+ return goalStatus === 'review' && merged ? 'done' : goalStatus;
1237
+ }
1238
+
1239
+ // Build the proof PR body (EN + JA, no emoji) and the PROOF.md committed to
1240
+ // the branch. In private repos, raw.githubusercontent URLs 404 inside PR
1241
+ // bodies (GitHub's image proxy cannot authenticate), so images live in a
1242
+ // committed PROOF.md whose blob page renders them for any signed-in member.
1243
+ export function buildProofBody({ owner, branch, proofRel, shotName, videoName, gifName, passCondition, attempts, appRel }) {
1244
+ const blob = (rel) => `https://github.com/${owner}/blob/${branch}/${rel.replace(/\\/g, '/')}`;
1245
+ const proofPage = blob(`${proofRel}/PROOF.md`);
1246
+ const tried = attempts.map((a) => `${a.attempt}:${a.pass ? 'pass' : 'fail'}`).join(', ');
1247
+
1248
+ const proofMd = [
1249
+ `# Proof / 動作確認`,
1250
+ ``,
1251
+ `Pass condition / 成功条件: ${passCondition}`,
1252
+ ``,
1253
+ `Verified independently by Manager for AI in a headless browser. The worker's self-report is never trusted.`,
1254
+ `Manager for AI が headless ブラウザで独立検証しました(workerの自己申告は信用しない設計)。`,
1255
+ ``,
1256
+ gifName ? `## Video / 動画(自動再生)\n\n![demo](./${gifName})\n` : '',
1257
+ `## Screenshot / スクリーンショット`,
1258
+ ``,
1259
+ `![proof](./${shotName})`,
1260
+ ``,
1261
+ videoName ? `Full-quality video / 高画質mp4: [${videoName}](./${videoName})\n` : '',
1262
+ `Attempts / 試行: ${tried}`,
1263
+ ].filter((l) => l !== '').join('\n');
1264
+
1265
+ const body = [
1266
+ `## Proof / 動作確認 — one tap, plays in place / 1タップでその場で動きます`,
1267
+ ``,
1268
+ `${proofPage}`,
1269
+ ``,
1270
+ `(GitHub cannot render images inside private-repo PR bodies, so the proof video (GIF) and screenshot auto-play on the page above.)`,
1271
+ `(privateリポジトリのPR本文には画像を出せないGitHub仕様のため、上のページでproof動画(GIF)とスクショが自動再生されます。)`,
1272
+ ``,
1273
+ `## What this proves / このPRが証明すること`,
1274
+ ``,
1275
+ `Pass condition / 成功条件: ${passCondition}`,
1276
+ ``,
1277
+ `Manager for AI verified this change independently in a headless browser (video + screenshot recorded at verification time). The worker's self-report is never trusted.`,
1278
+ `Manager for AI が headless ブラウザで独立検証しました(検証時に動画とスクショを記録。workerの自己申告は信用しない設計)。`,
1279
+ ``,
1280
+ videoName ? `Full-quality video / 高画質mp4: ${blob(`${proofRel}/${videoName}`)}` : '',
1281
+ ``,
1282
+ `Attempts / 試行: ${tried} — Target / 対象: \`${appRel}\``,
1283
+ ].filter((l) => l !== '').join('\n');
1284
+
1285
+ return { body, proofMd };
1286
+ }
1287
+
1288
+ // Parse `git log --pretty=format:%H%x1f%ad%x1f%an%x1f%s --date=iso-strict`
1289
+ // output (one commit per line, fields separated by \x1f) into
1290
+ // [{hash, date, author, subject}], newest-first (git's own order).
1291
+ export function parseGitLog(rawText) {
1292
+ return String(rawText ?? '')
1293
+ .split('\n')
1294
+ .map((line) => line.trim())
1295
+ .filter(Boolean)
1296
+ .map((line) => {
1297
+ const [hash, date, author, ...rest] = line.split('\x1f');
1298
+ return { hash: hash ?? '', date: date ?? '', author: author ?? '', subject: rest.join('\x1f') };
1299
+ });
1300
+ }
1301
+
1302
+ // `git status --porcelain` is empty (only whitespace) exactly when the
1303
+ // working tree has nothing to commit. Used to skip a rework push when a
1304
+ // reply task changed nothing (e.g. a comment-only follow-up) instead of
1305
+ // letting `git commit` fail on an empty tree.
1306
+ export function hasGitChanges(porcelainStatus) {
1307
+ return Boolean(String(porcelainStatus ?? '').trim());
1308
+ }
1309
+
1310
+ // ---- merge-time conflict detection -----------------------------------------
1311
+ // Parallel goals never corrupt each other's WORKING files (each runs in its
1312
+ // own worktree) — but two goals that edited the same repo path will still
1313
+ // conflict when both branches try to land on main. Detection is deterministic
1314
+ // (plain path-set overlap, no LLM) and runs at goal-finish time (server.mjs
1315
+ // updateGoalConflicts), comparing the just-finished goal's changed files
1316
+ // against siblings that are still running or awaiting merge.
1317
+
1318
+ // Do these two file lists share at least one path? Order-independent, and
1319
+ // an empty/missing list on either side can never "conflict" (nothing to
1320
+ // overlap with — most commonly a no-code goal, or a sibling whose worker
1321
+ // hasn't changed anything yet).
1322
+ export function goalsConflict(filesA, filesB) {
1323
+ const a = filesA ?? [], b = filesB ?? [];
1324
+ if (!a.length || !b.length) return false;
1325
+ const setA = new Set(a);
1326
+ return b.some((f) => setA.has(f));
1327
+ }
1328
+
1329
+ // Which of `otherGoals` (each `{ id, changedFiles }`) touch at least one path
1330
+ // this goal (`{ id, changedFiles }`) also touched? Returns their ids, in the
1331
+ // order they appear in `otherGoals` — server.mjs stamps these onto
1332
+ // `goal.conflictsWith` (and, reciprocally, onto each flagged sibling) so
1333
+ // Review can surface "touches the same files as #N".
1334
+ export function detectConflicts(goal, otherGoals) {
1335
+ const files = goal?.changedFiles ?? [];
1336
+ if (!files.length) return [];
1337
+ return (otherGoals ?? [])
1338
+ .filter((g) => g && g.id !== goal.id && goalsConflict(files, g.changedFiles))
1339
+ .map((g) => g.id);
1340
+ }
1341
+
1342
+ // Which of `commits` (newest-first, as returned by parseGitLog) are new
1343
+ // since we last synced up to `lastSeenHash`? null means nothing has been
1344
+ // seen yet, so every commit is new. If lastSeenHash isn't found in the
1345
+ // list (e.g. history was rewritten), treat everything as new rather than
1346
+ // silently dropping commits.
1347
+ export function diffNewCommits(commits, lastSeenHash) {
1348
+ if (lastSeenHash == null) return commits;
1349
+ const idx = commits.findIndex((c) => c.hash === lastSeenHash);
1350
+ if (idx === -1) return commits;
1351
+ return commits.slice(0, idx);
1352
+ }
1353
+
1354
+ // Task 45/46/62 verification: a freshly booted ephemeral instance of the
1355
+ // Manager app (see server.mjs's startEphemeralApp) starts with zero
1356
+ // history, but a verify check for completion-time behavior (e.g. "no PR
1357
+ // toggle -> no PR at completion", or "the Review section shows this
1358
+ // project's review description") needs real finished/settled examples to
1359
+ // inspect. Running an actual worker task there to produce one would spawn a
1360
+ // live Claude Code worker back onto this very project directory — and since
1361
+ // the ephemeral instance has no other goal running, a brand-new goal
1362
+ // created there does NOT queue harmlessly: it goes straight to 'planning'
1363
+ // and kicks off a REAL background `claude` process immediately, which takes
1364
+ // far longer than any UI wait is willing to sit through. So instead these
1365
+ // already-settled example goals are seeded directly into the ephemeral
1366
+ // instance's queue log before it boots — same JSONL shape replayQueueLog
1367
+ // reads on every real restart:
1368
+ // 1) finished with a PR (wantsPR: true, pr set)
1369
+ // 2) finished without a PR (wantsPR: false, no pr)
1370
+ // 3) already sitting in 'review' WITH a PR — app/index.html's
1371
+ // renderTasks() only renders the Review section (and the project's
1372
+ // review description next to it) when at least one goal has status
1373
+ // 'review', so without this fixture there is no way to see that text
1374
+ // rendered short of waiting out a real worker run.
1375
+ // 4) already sitting in 'review' WITHOUT a PR, with 8 requirement tasks —
1376
+ // this is the only way to exercise the PR-less review checklist
1377
+ // (#reviewOverlay, task 62): that "Review checklist" button only
1378
+ // renders for a review-status goal with g.pr unset (app/index.html),
1379
+ // so a seeded goal that has a PR (like #3 above) can never open it.
1380
+ // Each requirement task carries proof (server.mjs's seedEphemeralHistory
1381
+ // writes matching proof-<id>/proof.gif files to disk) so the checklist's
1382
+ // video/GIF slot has something real to render, not the "no video"
1383
+ // placeholder.
1384
+ // 5) a failed/interrupted To Do row with Retry/View log/Archive nearby — so
1385
+ // design proofs for Attention/failed-row work can capture the actual
1386
+ // target UI instead of a generic empty page or the wrong lane.
1387
+ export function buildEphemeralSeedLines(at = '2026-01-01T00:00:00.000Z') {
1388
+ const reqTitles = [
1389
+ 'ヘッダーにロゴを表示', 'ダークモード切り替え', '検索ボックスの実装', '一覧の無限スクロール',
1390
+ '通知バッジのカウント', 'ログイン画面のバリデーション', 'エクスポートCSVボタン', 'モバイル幅でのレイアウト崩れ修正',
1391
+ ];
1392
+ const reqTasks = reqTitles.map((title, i) => ({
1393
+ kind: 'task', id: 8 + i, num: i + 1, goalId: 7, projectId: 'default', title,
1394
+ detail: `${title}について、要求どおりの見た目・挙動になっているかを確認する。`,
1395
+ passCondition: `${title}が画面上で確認できること`, model: 'sonnet', status: 'done', createdAt: at,
1396
+ result: 'ok', changedFiles: ['fixture.txt'], secs: 1,
1397
+ proof: { pass: true, detail: 'ok', gif: 'proof.gif', png: null, mp4: null }, usage: null, finishedAt: at,
1398
+ }));
1399
+ return [
1400
+ { kind: 'goal', id: 1, projectId: 'default', text: '[verify fixture] PRあり完了ゴール', status: 'done', wantsPR: true, model: 'sonnet', images: [], plan: ['fixture task'], pr: 'https://github.com/example/verify-fixture/pull/1', createdAt: at },
1401
+ { kind: 'task', id: 2, num: 1, goalId: 1, projectId: 'default', title: 'fixture task', detail: '', passCondition: '', model: 'sonnet', status: 'done', createdAt: at, result: 'ok', changedFiles: ['fixture.txt'], secs: 1, proof: { pass: true, detail: 'ok', gif: null, png: null, mp4: null }, usage: null, finishedAt: at },
1402
+ { kind: 'goal', id: 3, projectId: 'default', text: '[verify fixture] PRなし完了ゴール', status: 'done', wantsPR: false, model: 'sonnet', images: [], plan: ['fixture task'], pr: undefined, createdAt: at },
1403
+ { kind: 'task', id: 4, num: 1, goalId: 3, projectId: 'default', title: 'fixture task', detail: '', passCondition: '', model: 'sonnet', status: 'done', createdAt: at, result: 'ok', changedFiles: ['fixture.txt'], secs: 1, proof: { pass: true, detail: 'ok', gif: null, png: null, mp4: null }, usage: null, finishedAt: at },
1404
+ { kind: 'goal', id: 5, projectId: 'default', text: '[verify fixture] レビュー待ちゴール(PRあり)', status: 'review', wantsPR: true, model: 'sonnet', images: [], plan: ['fixture task'], pr: 'https://github.com/example/verify-fixture/pull/2', createdAt: at },
1405
+ { kind: 'task', id: 6, num: 1, goalId: 5, projectId: 'default', title: 'fixture task', detail: '', passCondition: '', model: 'sonnet', status: 'done', createdAt: at, result: 'ok', changedFiles: ['fixture.txt'], secs: 1, proof: { pass: true, detail: 'ok', gif: null, png: null, mp4: null }, usage: null, finishedAt: at },
1406
+ { kind: 'goal', id: 7, projectId: 'default', text: '[verify fixture] レビュー待ちゴール(PRなし・要件8件)', status: 'review', wantsPR: false, model: 'sonnet', images: [], plan: reqTitles, pr: undefined, createdAt: at },
1407
+ ...reqTasks,
1408
+ { kind: 'goal', id: 16, projectId: 'default', text: '[verify fixture] 失敗したTo Do行', status: 'partial', wantsPR: false, model: 'sonnet', images: [], plan: ['失敗行のアクション確認'], pr: undefined, createdAt: at },
1409
+ { kind: 'task', id: 17, num: 9, goalId: 16, projectId: 'default', title: '失敗行のアクション確認', detail: '', passCondition: null, model: 'sonnet', status: 'failed', createdAt: at, result: 'テスト失敗 4 件', changedFiles: ['app/index.html'], secs: 1, proof: null, usage: null, finishedAt: at },
1410
+ ];
1411
+ }
1412
+
1413
+ export function buildEphemeralSeedLog(at) {
1414
+ return buildEphemeralSeedLines(at).map((l) => JSON.stringify(l)).join('\n') + '\n';
1415
+ }
1416
+
1417
+ // Task 57 verification: the project sidebar needs a second project to click
1418
+ // to prove switching works, but a fresh ephemeral instance (see server.mjs's
1419
+ // startEphemeralApp) has no projects.json of its own and would otherwise
1420
+ // auto-generate exactly one ('default', named after cwd) — never enough to
1421
+ // exercise a switch. Seed a second real entry alongside it; 'default' stays
1422
+ // first and keeps its id so it still matches the projectId the seeded goals/
1423
+ // tasks above (buildEphemeralSeedLines) reference.
1424
+ export function buildEphemeralSeedProjects(cwd) {
1425
+ return [
1426
+ { id: 'default', name: basename(cwd), dir: cwd },
1427
+ { id: 'default-2', name: '[verify fixture] second project', dir: cwd },
1428
+ ];
1429
+ }
1430
+
1431
+ // Rebuild in-memory { goals, tasks } from the restart-safe queue's
1432
+ // append-only JSONL log (engine/chat-runs/tasks.jsonl — one line per
1433
+ // goal/task write). This is what a server restart replays from disk, so a
1434
+ // goal/task written once (e.g. a memo the user wants to survive a restart)
1435
+ // is never lost: later lines for the same id override earlier ones (last
1436
+ // write wins), and a goal write with `deleted:true` removes it. Mid-flight
1437
+ // work can't resume across a restart, so it's marked retryable rather than
1438
+ // silently dropped: a 'running' task becomes 'interrupted', a 'planning'
1439
+ // goal goes back to 'stacked', and a 'running' goal with no queued task left
1440
+ // settles via nextGoalStatus() — a wantsPR goal lands in 'review' just like
1441
+ // the normal completion path, even if the crash happened before the PR got
1442
+ // created (the caller re-attempts createGoalPR for those on restart).
1443
+ // `reviewDefinitions` (projectId -> definition, as saved by
1444
+ // validateReviewDefinition) lets that settling honor each project's own
1445
+ // review definition (task 45); a project with none configured falls back to
1446
+ // DEFAULT_REVIEW_DEFINITION.
1447
+ export function replayQueueLog(rawText, reviewDefinitions = {}) {
1448
+ let goals = [];
1449
+ let tasks = [];
1450
+ for (const line of String(rawText ?? '').split('\n')) {
1451
+ if (!line.trim()) continue;
1452
+ try {
1453
+ const e = JSON.parse(line);
1454
+ if (e.kind === 'goal') { goals = goals.filter((g) => g.id !== e.id); if (!e.deleted) goals.push(e); }
1455
+ else { tasks = tasks.filter((t) => t.id !== e.id); tasks.push(e); }
1456
+ } catch { /* skip a corrupt line rather than lose the whole log */ }
1457
+ }
1458
+ for (const t of tasks) {
1459
+ if (t.status === 'running') t.status = 'interrupted';
1460
+ }
1461
+ for (const g of goals) {
1462
+ if (g.status === 'planning') g.status = 'stacked';
1463
+ else if (g.status === 'running' && !tasks.some((t) => t.goalId === g.id && t.status === 'queued')) {
1464
+ const gt = tasks.filter((t) => t.goalId === g.id);
1465
+ const allDone = gt.length > 0 && gt.every((t) => t.status === 'done');
1466
+ const allVerified = gt.every((t) => t.proof?.pass === true);
1467
+ const reviewDefinition = reviewDefinitions[g.projectId] ?? DEFAULT_REVIEW_DEFINITION;
1468
+ g.status = nextGoalStatus({ allDone, wantsPR: g.wantsPR, allVerified, reviewDefinition });
1469
+ }
1470
+ }
1471
+ const nextId = Math.max(0, ...goals.map((g) => g.id), ...tasks.map((t) => t.id)) + 1;
1472
+ const prioCounter = Math.max(0, ...goals.map((g) => g.prio ?? 0));
1473
+ return { goals, tasks, nextId, prioCounter };
1474
+ }
1475
+
1476
+ // Boot-time safety net for goals a dead server left with no live worker
1477
+ // (Masa dogfood: the To Do pile-up from a goal that never actually resumes).
1478
+ // replayQueueLog() above already stops a 'running' goal with no 'queued'
1479
+ // sibling task from staying 'running' forever — it settles such a goal via
1480
+ // nextGoalStatus() into 'review' or 'partial'. So under the CURRENT
1481
+ // replayQueueLog contract, this function is defense in depth: it should never
1482
+ // find anything to do against a ledger replayQueueLog itself produced. It
1483
+ // exists as an explicit, independently-tested guarantee — scoped to exactly
1484
+ // what it says — so that a future change to replayQueueLog's settling logic
1485
+ // (or a hand-edited/legacy ledger line) can never leave a goal fake-'running'
1486
+ // with nothing that will ever pick it up again.
1487
+ //
1488
+ // Deliberately narrow: only 'running' goals with no 'queued' sibling task are
1489
+ // reaped. An earlier version of this function also reclassified 'partial'
1490
+ // goals that had an 'interrupted' sibling task (replayQueueLog's own
1491
+ // settling outcome for "some task didn't finish") — that's a real, separate
1492
+ // UX gap (such a goal reads as "Done" via goalGroupStatus, which can hide a
1493
+ // restart interruption), but it collided with a legitimate, unrelated case:
1494
+ // a goal deliberately left 'running' with an 'interrupted' REPLY task
1495
+ // awaiting a human's Retry/Skip (see goal-rework-updates-existing-pr.test.mjs)
1496
+ // is NOT an orphan — closing that task via /skip must still be able to
1497
+ // re-settle the goal through finishGoalIfComplete, which only proceeds from
1498
+ // 'running'/'partial'. Reclassifying the goal to 'interrupted' up front
1499
+ // permanently blocked that. Widening finishGoalIfComplete's own gate to also
1500
+ // accept 'interrupted' would be the correct follow-up for that separate gap,
1501
+ // but is out of scope here.
1502
+ //
1503
+ // Pure: takes/returns plain data, no I/O. The caller (server.mjs, right after
1504
+ // replayQueueLog at boot) applies { status, reason } to its own goal objects
1505
+ // and saves them.
1506
+ export function reconcileOrphanGoals(goals, tasks) {
1507
+ const out = [];
1508
+ for (const g of goals ?? []) {
1509
+ if (g.status !== 'running') continue;
1510
+ const hasQueued = (tasks ?? []).some((t) => t.goalId === g.id && t.status === 'queued');
1511
+ if (hasQueued) continue; // a queued sibling auto-resumes at boot — genuinely resuming, leave alone
1512
+ out.push({ id: g.id, status: 'interrupted', reason: 'サーバ再起動により作業が中断されました。再試行してください。' });
1513
+ }
1514
+ return out;
1515
+ }
1516
+
1517
+ // The four states the engine actually tracks per goal/task (see
1518
+ // goalGroupStatus/goalChip above). A project's workflow columns are a
1519
+ // display-layer relabeling/reordering on top of these — every column must
1520
+ // still map to one of them so existing status logic keeps working.
1521
+ export const WORKFLOW_BUCKETS = ['todo', 'doing', 'review', 'done'];
1522
+
1523
+ // Tools a `claude -p` worker may call (--allowedTools). TodoWrite must stay
1524
+ // in this list — it's how the UI's To-do section (sendTodos / SSE 'todos')
1525
+ // gets any data at all; without it workers can never emit {kind:'todos'}.
1526
+ export const WORKER_TOOLS = 'Edit,Write,Read,Glob,Grep,Skill,TodoWrite,Bash(node:*),Bash(npm:*),Bash(npx:*)';
1527
+
1528
+ // ---- SKILL picker ----------------------------------------------------------
1529
+ // Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`
1530
+ // and `description`. Pure string→object; returns null when there's no name.
1531
+ // Handles single-line `key: value` (optionally quoted); long folded/multi-line
1532
+ // descriptions collapse to their first line (fine — the picker truncates).
1533
+ export function parseSkillFrontmatter(md) {
1534
+ const text = String(md ?? '');
1535
+ const m = text.match(/^?---[ \t]*\r?\n([\s\S]*?)\r?\n---/);
1536
+ if (!m) return null;
1537
+ const block = m[1];
1538
+ const bLines = block.split('\n');
1539
+ const grab = (key) => {
1540
+ const re = new RegExp(`^${key}[ \\t]*:[ \\t]*(.*)$`);
1541
+ const idx = bLines.findIndex((l) => re.test(l));
1542
+ if (idx < 0) return null;
1543
+ let v = bLines[idx].match(re)[1].trim();
1544
+ // YAML block scalar (description: > or |): fold the following indented lines.
1545
+ if (v === '>' || v === '|' || /^[>|][+-]?$/.test(v)) {
1546
+ const folded = [];
1547
+ for (let i = idx + 1; i < bLines.length; i++) {
1548
+ if (!/^\s+\S/.test(bLines[i]) && bLines[i].trim() !== '') break;
1549
+ folded.push(bLines[i].trim());
1550
+ }
1551
+ v = folded.join(' ').trim();
1552
+ }
1553
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
1554
+ return v.trim();
1555
+ };
1556
+ const name = grab('name');
1557
+ if (!name) return null;
1558
+ return { name, description: grab('description') ?? '' };
1559
+ }
1560
+
1561
+ function truncateSkillDesc(s, n = 140) {
1562
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
1563
+ return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t;
1564
+ }
1565
+
1566
+ // Read a resolved list of skill/command source directories and return a
1567
+ // de-duplicated list of { name, description, source, kind }. Each source is
1568
+ // { dir, source:'user'|'project', kind:'skill'|'command' }:
1569
+ // kind 'skill' → dir has <name>/SKILL.md subfolders
1570
+ // kind 'command' → dir has *.md files
1571
+ // Dedup is by kind:name, first occurrence wins (pass user sources before
1572
+ // project so a project override doesn't hide the user one silently — both are
1573
+ // the same name anyway). Missing dirs are skipped, not an error.
1574
+ export function collectSkills(sources = []) {
1575
+ const out = [];
1576
+ const seen = new Set();
1577
+ const add = (name, description, source, kind) => {
1578
+ const k = `${kind}:${name}`;
1579
+ if (seen.has(k)) return;
1580
+ seen.add(k);
1581
+ out.push({ name, description: truncateSkillDesc(description), source, kind });
1582
+ };
1583
+ for (const src of sources) {
1584
+ if (!src || !src.dir || !existsSync(src.dir)) continue;
1585
+ if (src.kind === 'skill') {
1586
+ let entries = [];
1587
+ try { entries = readdirSync(src.dir, { withFileTypes: true }); } catch { continue; }
1588
+ for (const e of entries) {
1589
+ if (!e.isDirectory()) continue;
1590
+ const md = join(src.dir, e.name, 'SKILL.md');
1591
+ if (!existsSync(md)) continue;
1592
+ let parsed = null;
1593
+ try { parsed = parseSkillFrontmatter(readFileSync(md, 'utf8')); } catch { /* unreadable */ }
1594
+ add(parsed?.name || e.name, parsed?.description, src.source, 'skill');
1595
+ }
1596
+ } else if (src.kind === 'command') {
1597
+ let files = [];
1598
+ try { files = readdirSync(src.dir); } catch { continue; }
1599
+ for (const f of files.sort()) {
1600
+ if (!f.endsWith('.md')) continue;
1601
+ let parsed = null;
1602
+ try { parsed = parseSkillFrontmatter(readFileSync(join(src.dir, f), 'utf8')); } catch { /* unreadable */ }
1603
+ add(parsed?.name || f.replace(/\.md$/, ''), parsed?.description, src.source, 'command');
1604
+ }
1605
+ }
1606
+ }
1607
+ return out;
1608
+ }
1609
+
1610
+ export const DEFAULT_WORKFLOW_COLUMNS = [
1611
+ { key: 'todo', label: 'To do', bucket: 'todo' },
1612
+ { key: 'doing', label: 'Doing', bucket: 'doing' },
1613
+ { key: 'review', label: 'Review', bucket: 'review' },
1614
+ { key: 'done', label: 'Done', bucket: 'done' },
1615
+ ];
1616
+
1617
+ function slugifyColumnKey(label, i) {
1618
+ const s = String(label ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1619
+ return s || `col${i + 1}`;
1620
+ }
1621
+
1622
+ // Validate + normalize a project's custom workflow column list (from the
1623
+ // column editor's Save button). Every column must carry a non-empty label
1624
+ // and a bucket in WORKFLOW_BUCKETS. A bucket left with no column at all just
1625
+ // falls back to its English default label (see pickColumnLabel) — tasks/goals
1626
+ // in that bucket keep rendering under the fallback section, so dropping a
1627
+ // column (even the last one for a bucket) is safe and must stay allowed;
1628
+ // the task board itself is driven by real status, not by this column list.
1629
+ // Column keys default to a slug of the label and are de-duped, so the caller
1630
+ // doesn't have to invent one for a brand-new column.
1631
+ export function validateWorkflowColumns(input) {
1632
+ if (!Array.isArray(input) || !input.length) return { ok: false, error: 'at least one column is required' };
1633
+ if (input.length > 10) return { ok: false, error: 'at most 10 columns allowed' };
1634
+ const labels = input.map((c) => String(c?.label ?? '').trim().slice(0, 40));
1635
+ if (labels.some((l) => !l)) return { ok: false, error: 'every column needs a name' };
1636
+ const buckets = input.map((c) => c?.bucket);
1637
+ if (buckets.some((b) => !WORKFLOW_BUCKETS.includes(b))) {
1638
+ return { ok: false, error: `bucket must be one of: ${WORKFLOW_BUCKETS.join(', ')}` };
1639
+ }
1640
+ const seen = new Set();
1641
+ const columns = input.map((c, i) => {
1642
+ let key = String(c?.key ?? '').trim().slice(0, 40) || slugifyColumnKey(labels[i], i);
1643
+ while (seen.has(key)) key = `${key}-${i}`;
1644
+ seen.add(key);
1645
+ return { key, label: labels[i], bucket: c.bucket };
1646
+ });
1647
+ return { ok: true, columns };
1648
+ }
1649
+
1650
+ // First column whose bucket matches (a project may rename "Review" to
1651
+ // "審査" etc.) — used to render the Tasks-panel section headers with the
1652
+ // project's own column names instead of hardcoded English labels. Falls
1653
+ // back to `fallback` when no column claims that bucket (shouldn't happen
1654
+ // for a validateWorkflowColumns-saved list, but keeps rendering safe).
1655
+ // Mirrored manually in app/index.html since that inline script can't import
1656
+ // this module — keep both in sync.
1657
+ export function pickColumnLabel(columns, bucket, fallback) {
1658
+ return (columns ?? []).find((c) => c.bucket === bucket)?.label || fallback;
1659
+ }
1660
+
1661
+ // Merge a project's goals with git commits made outside the Manager (e.g.
1662
+ // via the Claude Code CLI directly) into one chronological (oldest-first)
1663
+ // timeline for the UI's stream. Both inputs are filtered to `projectId`
1664
+ // first, then tagged with `kind` so the renderer knows which template to use.
1665
+ // Mirrored manually in app/index.html's renderStream() — browser script
1666
+ // isn't a module and can't import server-side code.
1667
+ export function mergeGoalTimeline(goals, externalActivity, projectId) {
1668
+ const goalItems = (goals ?? [])
1669
+ .filter((g) => g.projectId === projectId && g.status !== 'stacked')
1670
+ .map((g) => ({ kind: 'goal', ts: Date.parse(g.createdAt) || 0, data: g }));
1671
+ const commitItems = (externalActivity ?? [])
1672
+ .filter((c) => c.projectId === projectId)
1673
+ .map((c) => ({ kind: 'commit', ts: Date.parse(c.date) || 0, data: c }));
1674
+ return [...goalItems, ...commitItems].sort((a, b) => a.ts - b.ts);
1675
+ }
1676
+
1677
+ // To do list display cap (task 88): first `limit` rows unless expanded.
1678
+ export function visibleTodos(list, showAll, limit = 10) {
1679
+ return showAll ? list : list.slice(0, limit);
1680
+ }
1681
+
1682
+ // Goal-level conclusion summary (task 84): composed deterministically from
1683
+ // worker reports — no LLM call, no raw-result concatenation. Always 3-6
1684
+ // Japanese lines covering ①何をやったか ②変更点 ③確認方法, even when tasks
1685
+ // are mixed done/failed/interrupted or there is only a single task.
1686
+ export function buildGoalSummary({ goalText, tasks } = {}) {
1687
+ const work = (tasks ?? []).filter((t) => !t.reply);
1688
+ const done = work.filter((t) => ['done', 'skipped'].includes(t.status));
1689
+ const failed = work.filter((t) => ['failed', 'interrupted'].includes(t.status));
1690
+ const files = [...new Set(work.flatMap((t) => t.changedFiles ?? []))];
1691
+ const proofs = work.filter((t) => t.proof?.pass).length;
1692
+
1693
+ const title = (goalText ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
1694
+ const lines = [];
1695
+ lines.push(`${title || 'ゴール'}: ${done.length}/${work.length}件完了${failed.length ? `(${failed.length}件は未完了・要対応)` : ''}`);
1696
+
1697
+ const highlights = done.slice(0, 3).map((t) => {
1698
+ const first = (t.result ?? t.detail ?? '')
1699
+ .replace(/^検証(PASS|FAIL)[^\n]*\n+/, '')
1700
+ .split('\n')[0]
1701
+ .replace(/\s+/g, ' ')
1702
+ .trim()
1703
+ .slice(0, 70);
1704
+ return first ? `${t.title}: ${first}` : t.title;
1705
+ });
1706
+ if (highlights.length) lines.push(`何をやったか: ${highlights.join(' / ')}`);
1707
+
1708
+ if (failed.length) {
1709
+ const names = failed.slice(0, 3).map((t) => t.title).join(' / ');
1710
+ lines.push(`未完了: ${names}${failed.length > 3 ? ` 他${failed.length - 3}件` : ''}`);
1711
+ }
1712
+
1713
+ lines.push(files.length
1714
+ ? `変更点: ${files.slice(0, 8).join(' · ')}${files.length > 8 ? ` +${files.length - 8}件` : ''}`
1715
+ : '変更点: コード変更なし');
1716
+
1717
+ lines.push(proofs
1718
+ ? `確認方法: proof付きで${proofs}件検証済み。レビュー欄で詳細を確認してください。`
1719
+ : '確認方法: 各タスクの実行結果を個別に確認してください。');
1720
+
1721
+ return lines.join('\n');
1722
+ }
1723
+
1724
+ // Plain-text snapshot of Review/Attention/Done fed to the /api/ask LLM pass —
1725
+ // same three buckets app/index.html's renderTasks() renders (goal.status
1726
+ // 'review'/'blocked' + task.status failed/interrupted + task.status
1727
+ // done/skipped), flattened into text since there's no UI to look at here.
1728
+ export function buildAskContext(goals = [], tasks = []) {
1729
+ const label = (g) => (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
1730
+
1731
+ const reviewGoals = goals.filter((g) => g.status === 'review');
1732
+ const reviewLines = reviewGoals.map((g) => {
1733
+ const tr = g.testResult;
1734
+ const tests = tr?.ran ? `tests ${tr.passed}✓${tr.failed ? `/${tr.failed}✗` : ''}` : 'no tests';
1735
+ return `- [goal ${g.id}] ${label(g)} (${tests}${g.pr ? `, PR: ${g.pr}` : ''})`;
1736
+ });
1737
+
1738
+ const blockedGoals = goals.filter((g) => g.status === 'blocked');
1739
+ const attnTasks = tasks.filter((t) => ['failed', 'interrupted'].includes(t.status));
1740
+ const attentionLines = [
1741
+ ...blockedGoals.map((g) => `- [goal ${g.id}] ${label(g)} — blocked: ${(g.blocked?.reason ?? 'blocked').replace(/\s+/g, ' ').slice(0, 100)}`),
1742
+ ...attnTasks.map((t) => `- [task ${t.id}] ${(t.title ?? t.text ?? '').replace(/\s+/g, ' ').slice(0, 80)} — ${t.status}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 100)}`),
1743
+ ];
1744
+
1745
+ const doneTasks = tasks.filter((t) => ['done', 'skipped'].includes(t.status)).slice(-30);
1746
+ const doneLines = doneTasks.map((t) => `- [task ${t.id}] ${(t.title ?? t.text ?? '').replace(/\s+/g, ' ').slice(0, 80)}`);
1747
+
1748
+ const section = (title, lines) => [`## ${title}`, lines.length ? lines.join('\n') : '(なし)'].join('\n');
1749
+ return [
1750
+ section('Review(レビュー待ち)', reviewLines),
1751
+ section('Attention(要対応)', attentionLines),
1752
+ section('Done(完了)', doneLines),
1753
+ ].join('\n\n');
1754
+ }
1755
+
1756
+ // Goal statuses that count as "finished" for showing the goal-level
1757
+ // conclusion summary in chat (task 86) — mirrored in app/index.html's
1758
+ // goalRow (GOAL_COMPLETE_STATUSES) since that inline script can't import
1759
+ // this module. A goal still 'planning'/'running'/'sending' has no settled
1760
+ // outcome yet even if a stale summary happens to linger on the object, and
1761
+ // 'failed'/'stacked' goals never got a summary built for them.
1762
+ export const GOAL_COMPLETE_STATUSES = ['review', 'done', 'partial'];
1763
+ export function shouldShowGoalSummary(goal) {
1764
+ return !!goal?.summary && GOAL_COMPLETE_STATUSES.includes(goal?.status);
1765
+ }
1766
+
1767
+ // Review checklist recap (task 96): a short, deterministic summary of what a
1768
+ // goal set out to do and how far its requirements got — shown at the top of
1769
+ // the review checklist AND in the chat thread's review导线, so a reviewer sees
1770
+ // 「何をやったか・何を確認すればいいのか」without reading every worker report.
1771
+ // Mirrored in app/index.html (buildReviewSummary) since the inline browser
1772
+ // script can't import this module. No LLM — composed from goal.plan / task
1773
+ // titles / task状態.
1774
+ export function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}) {
1775
+ const reqs = (tasks ?? []).filter((t) => !t.reply);
1776
+ const total = reqs.length;
1777
+ const done = reqs.filter((t) => ['done', 'skipped'].includes(t.status)).length;
1778
+ const proofs = reqs.filter((t) => t.proof?.pass).length;
1779
+ const plan = Array.isArray(goal?.plan) ? goal.plan.filter(Boolean) : [];
1780
+ const source = plan.length ? plan : reqs.map((t) => t.title);
1781
+ const what = source
1782
+ .map((s) => String(s ?? '').replace(/\s+/g, ' ').trim())
1783
+ .filter(Boolean)
1784
+ .slice(0, 3)
1785
+ .join(' / ');
1786
+ const fallback = String(goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
1787
+ return {
1788
+ approvedN,
1789
+ total,
1790
+ headline: `${approvedN} / ${total} approved`,
1791
+ what: what || fallback,
1792
+ detail: `${done}/${total} done${proofs ? ` · proof ×${proofs}` : ''}`,
1793
+ failureMemory: goal?.failureMemory?.length ? goal.failureMemory[goal.failureMemory.length - 1] : null,
1794
+ tokenOptimization: estimateTokenOptimization({
1795
+ usage: goal?.runUsage,
1796
+ weightedTokens: goal?.runTokens,
1797
+ cacheReadWeight: goal?.runTokenWeight,
1798
+ sessionMode: goal?.executionPlan?.sessionMode,
1799
+ usedHandoff: goal?.executionPlan?.sessionMode === 'cold-handoff' || Boolean(goal?.contextHandoff),
1800
+ usedCompact: goal?.executionPlan?.sessionMode === 'compact',
1801
+ usedClear: Boolean(goal?.sessionClosedAt),
1802
+ agentModel: goal?.model,
1803
+ }),
1804
+ };
1805
+ }
1806
+
1807
+ // Task 114: the persistent bottom-right Log box (formerly the running-task-only
1808
+ // #actpanel) defaults to collapsed (1 line) and expands on click. Plain boolean
1809
+ // flip, but pulled out as a pure function so the click handler has a unit test
1810
+ // instead of only being exercisable by clicking around in a browser.
1811
+ // Mirrored in app/index.html since that inline script can't import this module.
1812
+ export function toggleLogCollapsed(collapsed) {
1813
+ return !collapsed;
1814
+ }
1815
+
1816
+ // Task 120: dragging the handle between #tasklist (To Do) and #actpanel (Log)
1817
+ // sets the To Do list's explicit height directly off the mouse position; Log
1818
+ // always gets whatever height is left over. Clamped so neither side collapses
1819
+ // past readability — To Do keeps at least minTasks px, Log keeps at least
1820
+ // minLog px (its header row). Mirrored in app/index.html since that inline
1821
+ // script can't import this module.
1822
+ export function clampTasklistHeight(px, containerHeight, minTasks = 120, minLog = 44) {
1823
+ const max = Math.max(minTasks, containerHeight - minLog);
1824
+ return Math.min(max, Math.max(minTasks, px));
1825
+ }
1826
+
1827
+ // Task 115: turn an incoming SSE 'goal' message into one Log-box line,
1828
+ // based on what changed since the previously-known copy of that goal
1829
+ // (`prevGoal` is undefined the first time the client ever sees this goal id).
1830
+ // Only the milestones the Log box cares about are covered here — decomposed
1831
+ // into To-do tasks, shelved to Pending, and the goal-level Approve/Dismiss —
1832
+ // everything else (still 'planning', 'stacked' behind another goal, etc.)
1833
+ // returns null so it's simply not logged. Mirrored in app/index.html since
1834
+ // that inline script can't import this module.
1835
+ export function goalLogLine(goal, prevGoal) {
1836
+ const label = String(goal?.plan?.[0] ?? goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
1837
+ if (!prevGoal) return goal?.status === 'pending' ? `Goal shelved to Pending: "${label}"` : null;
1838
+ if (prevGoal.status === 'review' && goal.status === 'done') return `Approved: "${label}"`;
1839
+ if (prevGoal.status === 'review' && goal.status === 'running') return `Dismissed: "${label}" — back to work`;
1840
+ if (prevGoal.status !== 'running' && goal.status === 'running' && Array.isArray(goal.plan) && goal.plan.length) {
1841
+ return `Goal decomposed into ${goal.plan.length} task(s) → To do: "${label}"`;
1842
+ }
1843
+ return null;
1844
+ }
1845
+
1846
+ // Task 115: same idea as goalLogLine but for SSE 'task' messages — a task's
1847
+ // own start/complete/fail, plus a reply task's very first appearance (a
1848
+ // reply task is created already 'queued', with no earlier "queued" event of
1849
+ // its own to diff against, so that's the one case logged on first sight
1850
+ // instead of on a status change). Mirrored in app/index.html.
1851
+ export function taskLogLine(task, prevTask) {
1852
+ const title = String(task?.title ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
1853
+ const label = task?.reply ? `Reply "${title}"` : `Task ${task?.num} "${title}"`;
1854
+ if (!prevTask) return task?.reply ? `${label} queued — processed as thread reply` : null;
1855
+ if (prevTask.status !== 'running' && task.status === 'running') return `${label} started`;
1856
+ if (!['done', 'skipped'].includes(prevTask.status) && ['done', 'skipped'].includes(task.status)) return `${label} completed`;
1857
+ if (!['failed', 'interrupted'].includes(prevTask.status) && ['failed', 'interrupted'].includes(task.status)) return `${label} failed`;
1858
+ return null;
1859
+ }
1860
+
1861
+ // Ring buffer for the Log box (task 115): append one entry, keeping only the
1862
+ // most recent `limit` (default 50) — the oldest is dropped first once full.
1863
+ // Generic (not Log-box-specific) so any capped-history list can reuse it.
1864
+ export function pushCapped(list, item, limit = 50) {
1865
+ return [...(list ?? []), item].slice(-limit);
1866
+ }
1867
+
1868
+ // Task 116: the header's Working·n/Idle pill (#workstat) is retired in favor
1869
+ // of the same status living in the persistent Log box's own header. `runningAll`
1870
+ // is the count of tasks with status 'running' across every project (same
1871
+ // count the old pill used — not filtered to the active project). Mirrored in
1872
+ // app/index.html since that inline script can't import this module.
1873
+ export function workStatusLabel(runningAll) {
1874
+ return runningAll > 0 ? `Working · ${runningAll}` : 'Idle';
1875
+ }
1876
+
1877
+ // One-line recap of a single requirement (task 96): the worker's own first
1878
+ // line (detail preferred over its raw result), stripped of the 検証PASS/FAIL
1879
+ // prefix, for the per-requirement summary in both review surfaces.
1880
+ export function requirementSummaryLine(task) {
1881
+ return String(task?.detail ?? task?.result ?? '')
1882
+ .replace(/^検証(PASS|FAIL)[^\n]*\n+/, '')
1883
+ .split('\n')[0]
1884
+ .replace(/\s+/g, ' ')
1885
+ .trim()
1886
+ .slice(0, 120);
1887
+ }
1888
+
1889
+ // ============================================================================
1890
+ // See all Ledger — engine logic (HANDOFF-v45 §5 / PRD §5.1)
1891
+ // ============================================================================
1892
+
1893
+ // Plain Dismiss from the Ledger = a "できていない" declaration WITHOUT feedback
1894
+ // text. Instead of spawning a rework worker immediately (the dismiss-with-text
1895
+ // path), the engine re-runs the goal's deterministic verification ×3 first —
1896
+ // this is the decision that a retest may start.
1897
+ export function retestGoal({ goalStatus }) {
1898
+ if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1899
+ return { ok: true, status: 'retesting' };
1900
+ }
1901
+
1902
+ // UNDO of a Ledger Dismiss: only an in-flight retest can be cancelled — once
1903
+ // the job settled, the goal is already back in 'review' or reworking.
1904
+ export function cancelRetestGoal({ goalStatus }) {
1905
+ if (goalStatus !== 'retesting') return { ok: false, error: 'goal is not re-testing' };
1906
+ return { ok: true, status: 'review' };
1907
+ }
1908
+
1909
+ // Decide the retest outcome from the job's deterministic runs (npm test in
1910
+ // the goal's worktree — NO LLM). `total` is how many green runs are required.
1911
+ // - every run ran and stayed green, `total` times → back to 'review' with the
1912
+ // "retested ×3 — no issues found" note (PRD §5.1: 人に知らせて再提出)
1913
+ // - any run reproduces a failure → back to To Do as reworking ('running'),
1914
+ // with the failure evidence for the worker respawn
1915
+ // - the project has no test suite at all → back to 'review', honestly noting
1916
+ // the retest was skipped (never fabricate a green ×3)
1917
+ export function computeRetestOutcome(results = [], total = 3) {
1918
+ if (!results.length || results.some((r) => !r?.ran)) {
1919
+ return { verdict: 'skipped', status: 'review', note: 'retest skipped — no test suite' };
1920
+ }
1921
+ const failIdx = results.findIndex((r) => (r.failed ?? 0) > 0);
1922
+ if (failIdx >= 0) {
1923
+ const r = results[failIdx];
1924
+ return {
1925
+ verdict: 'fail', status: 'running',
1926
+ note: `retest ${failIdx + 1}/${total} — ${r.failed} test(s) failing`,
1927
+ evidence: `自動再テスト ${failIdx + 1}/${total} 回目で ${r.failed} 件のテスト失敗が再現(pass ${r.passed ?? 0} / fail ${r.failed})`,
1928
+ };
1929
+ }
1930
+ if (results.length < total) {
1931
+ // interrupted before reaching ×total with no failure seen — do not claim ×3
1932
+ return { verdict: 'skipped', status: 'review', note: `retest incomplete (${results.length}/${total} runs)` };
1933
+ }
1934
+ return { verdict: 'pass', status: 'review', note: `retested ×${total} — no issues found` };
1935
+ }
1936
+
1937
+ // Revert (ゴミ箱) = "make the change never have happened": close the PR,
1938
+ // delete its remote branch, drop the goal's worktree, and park the goal in a
1939
+ // terminal 'reverted' status (distinct from 'skipped'/archived). Decision only —
1940
+ // the destructive gh/git calls live behind the server's small wrapper.
1941
+ export function revertGoal({ goalStatus }) {
1942
+ if (!['review', 'done', 'blocked', 'partial'].includes(goalStatus)) {
1943
+ return { ok: false, error: 'only a finished (review/done/blocked/partial) goal can be reverted' };
1944
+ }
1945
+ return { ok: true, status: 'reverted' };
1946
+ }
1947
+
1948
+ // Which destructive steps a revert needs, from the goal's shape. Pure and
1949
+ // ordered: PR first (gh pr close --delete-branch removes the remote branch
1950
+ // with it), a pushed branch without a PR is deleted directly, and the local
1951
+ // worktree is removed last. No PR, no branch, no worktree → nothing but the
1952
+ // status flip.
1953
+ export function planRevertActions({ pr, prBranch, hasWorktree } = {}) {
1954
+ const actions = [];
1955
+ if (pr) actions.push({ kind: 'close-pr', url: pr, deleteBranch: !!prBranch });
1956
+ else if (prBranch) actions.push({ kind: 'delete-remote-branch', branch: prBranch });
1957
+ if (hasWorktree) actions.push({ kind: 'remove-worktree' });
1958
+ return actions;
1959
+ }
1960
+
1961
+ // Archive from the Ledger = 判断保留の棚上げ (PRD §5.1): review goals join the
1962
+ // statuses the /archive endpoint accepts. archivedFrom is remembered so the
1963
+ // one-liner's UNDO can put the goal back exactly where it was.
1964
+ export const ARCHIVABLE_GOAL_STATUSES = ['partial', 'failed', 'interrupted', 'blocked', 'review'];
1965
+ export function archiveGoal({ goalStatus }) {
1966
+ if (!ARCHIVABLE_GOAL_STATUSES.includes(goalStatus)) {
1967
+ return { ok: false, error: 'only an unfinished (partial/failed/interrupted/blocked) or review goal can be archived' };
1968
+ }
1969
+ return { ok: true, status: 'skipped', archivedFrom: goalStatus };
1970
+ }
1971
+
1972
+ // UNDO of a Ledger Archive. Restricted to goals archived FROM review — an
1973
+ // unfinished goal's archive also retired its child tasks, which this does not
1974
+ // resurrect, so pretending to undo those would be dishonest.
1975
+ export function unarchiveGoal({ goalStatus, archivedFrom }) {
1976
+ if (goalStatus !== 'skipped' || archivedFrom !== 'review') {
1977
+ return { ok: false, error: 'only a goal archived from review can be unarchived' };
1978
+ }
1979
+ return { ok: true, status: 'review' };
1980
+ }
1981
+
1982
+ // UNDO of a Ledger chat send-back (dismiss-with-text): only honest while the
1983
+ // rework reply task is still QUEUED — once a worker picked it up, the session
1984
+ // has already consumed the feedback and there is nothing true to undo.
1985
+ export function undismissGoal({ goalStatus, replyTask }) {
1986
+ if (goalStatus !== 'running') return { ok: false, error: 'goal is not reworking' };
1987
+ if (!replyTask || replyTask.status !== 'queued') {
1988
+ return { ok: false, error: 'the rework already started — cannot undo' };
1989
+ }
1990
+ return { ok: true, status: 'review' };
1991
+ }
1992
+
1993
+ // `git diff --numstat` → per-file adds/dels + totals for the Ledger meta row
1994
+ // (`+a −d`) and Details file list. Binary files report "-" — counted as 0.
1995
+ export function parseNumstat(raw) {
1996
+ const files = [];
1997
+ for (const line of String(raw ?? '').split('\n')) {
1998
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
1999
+ if (!m) continue;
2000
+ files.push({ path: m[3], add: m[1] === '-' ? 0 : Number(m[1]), del: m[2] === '-' ? 0 : Number(m[2]) });
2001
+ }
2002
+ return {
2003
+ files,
2004
+ add: files.reduce((s, f) => s + f.add, 0),
2005
+ del: files.reduce((s, f) => s + f.del, 0),
2006
+ };
2007
+ }
2008
+
2009
+ // Cap the diff text shipped to the Ledger Details block — a runaway diff
2010
+ // must not swamp /api/state-sized responses. Truncation is explicit so the
2011
+ // UI can say "… truncated" instead of silently lying about the change size.
2012
+ export function truncateDiffText(raw, maxLines = 400) {
2013
+ const text = String(raw ?? '');
2014
+ const lines = text.split('\n');
2015
+ if (lines.length <= maxLines) return { text, truncated: false, totalLines: lines.length };
2016
+ return { text: lines.slice(0, maxLines).join('\n'), truncated: true, totalLines: lines.length };
2017
+ }
2018
+
2019
+ // ---- Cloudflare Access (Google login) JWT verification --------------------
2020
+ // Stage 1 of "common URL → Google login": when the app sits behind Cloudflare
2021
+ // Access on its hostname, CF injects a signed RS256 JWT
2022
+ // (`Cf-Access-Jwt-Assertion` header) on every request *after* the visitor
2023
+ // logs in with Google. That header is attacker-controlled input on any box
2024
+ // that isn't actually behind CF Access, so it must be cryptographically
2025
+ // verified — signature, expiry, audience — never trusted at face value. All
2026
+ // I/O (fetching the JWKS, reading env vars) stays in server.mjs; these are
2027
+ // pure functions so the crypto logic itself is unit-testable without a
2028
+ // network call or a real Cloudflare team.
2029
+
2030
+ function base64urlToBuffer(s) {
2031
+ return Buffer.from(String(s ?? ''), 'base64url');
2032
+ }
2033
+
2034
+ // Split a compact JWT into its decoded header/payload plus the raw pieces
2035
+ // needed to re-verify the signature. Returns null for anything malformed
2036
+ // (wrong shape, bad base64url, non-JSON parts) instead of throwing.
2037
+ export function decodeJwtParts(token) {
2038
+ if (typeof token !== 'string') return null;
2039
+ const parts = token.split('.');
2040
+ if (parts.length !== 3 || parts.some((p) => !p)) return null;
2041
+ try {
2042
+ const header = JSON.parse(base64urlToBuffer(parts[0]).toString('utf8'));
2043
+ const payload = JSON.parse(base64urlToBuffer(parts[1]).toString('utf8'));
2044
+ if (!header || typeof header !== 'object' || !payload || typeof payload !== 'object') return null;
2045
+ return { header, payload, signingInput: `${parts[0]}.${parts[1]}`, signature: parts[2] };
2046
+ } catch {
2047
+ return null;
2048
+ }
2049
+ }
2050
+
2051
+ // `exp` is required — a JWT with no expiry is treated as already-expired
2052
+ // rather than eternally valid.
2053
+ export function jwtIsExpired(payload, nowSec = Math.floor(Date.now() / 1000)) {
2054
+ if (!payload || typeof payload.exp !== 'number') return true;
2055
+ return payload.exp <= nowSec;
2056
+ }
2057
+
2058
+ export function jwtAudMatches(payload, aud) {
2059
+ if (!payload || !aud) return false;
2060
+ const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
2061
+ return auds.includes(aud);
2062
+ }
2063
+
2064
+ export function pickJwk(jwks, kid) {
2065
+ const keys = jwks?.keys ?? [];
2066
+ if (!kid) return null;
2067
+ return keys.find((k) => k?.kid === kid) ?? null;
2068
+ }
2069
+
2070
+ // Full verification of a Cloudflare Access identity JWT: decode → find the
2071
+ // matching JWK by `kid` → verify the RS256 signature → check `exp` → check
2072
+ // `aud`. Returns `{ok:false}` on the first failure (never partially trusts a
2073
+ // token) and `{ok:true, email}` only once every check has passed.
2074
+ export function verifyCfAccessJwt({ token, jwks, aud, now = Math.floor(Date.now() / 1000) }) {
2075
+ const decoded = decodeJwtParts(token);
2076
+ if (!decoded) return { ok: false, email: null, reason: 'malformed' };
2077
+ const { header, payload, signingInput, signature } = decoded;
2078
+ const jwk = pickJwk(jwks, header.kid);
2079
+ if (!jwk) return { ok: false, email: null, reason: 'unknown-kid' };
2080
+ let signatureValid = false;
2081
+ try {
2082
+ const keyObject = createPublicKey({ key: jwk, format: 'jwk' });
2083
+ signatureValid = verifyRsaSignature('RSA-SHA256', Buffer.from(signingInput), keyObject, base64urlToBuffer(signature));
2084
+ } catch {
2085
+ signatureValid = false;
2086
+ }
2087
+ if (!signatureValid) return { ok: false, email: null, reason: 'bad-signature' };
2088
+ if (jwtIsExpired(payload, now)) return { ok: false, email: null, reason: 'expired' };
2089
+ if (!jwtAudMatches(payload, aud)) return { ok: false, email: null, reason: 'aud-mismatch' };
2090
+ if (!payload.email) return { ok: false, email: null, reason: 'no-email' };
2091
+ return { ok: true, email: payload.email };
2092
+ }
2093
+
2094
+ // ---- multi-user goal ownership (stage 2: "common URL → branch by user") --
2095
+ // Stage 1 (verifyCfAccessJwt above) gets us a per-request verified Google
2096
+ // identity (req.authEmail). Stage 2 partitions GOALS by that identity so
2097
+ // each allow-listed Google user sees only their own goals, while the OWNER
2098
+ // (Masa / key-auth) keeps seeing every goal, including the ones that predate
2099
+ // this feature and carry no `owner` field at all. Projects/skills/columns
2100
+ // stay shared for everyone — only goal (and by extension task) visibility
2101
+ // is partitioned.
2102
+
2103
+ // Resolve the *acting identity* for a request: 'owner' for key-auth (no
2104
+ // authEmail — the only path that existed before stage 1) and for
2105
+ // MANAGER_OWNER_EMAIL itself (so Masa logging in via Google still sees his
2106
+ // own pre-existing data), else the authed email verbatim. This is what gets
2107
+ // stamped onto a goal's `owner` field at creation time.
2108
+ export function resolveIdentity({ authEmail, ownerEmail } = {}) {
2109
+ if (!authEmail) return 'owner'; // key-auth
2110
+ if (ownerEmail && authEmail === ownerEmail) return 'owner';
2111
+ return authEmail;
2112
+ }
2113
+
2114
+ // Can `identity` see (act on) this goal? `identity` may already be resolved
2115
+ // via resolveIdentity ('owner' or an email) or be a raw authEmail — either
2116
+ // way, an identity equal to ownerEmail is treated as the owner. The owner
2117
+ // sees everything. A normal user sees only goals whose `owner` matches their
2118
+ // own identity. Backward compat is load-bearing here: a goal with no `owner`
2119
+ // field (every goal created before this feature) defaults to 'owner' —
2120
+ // visible to the owner, invisible to any other user — so nothing existing is
2121
+ // ever lost or hidden from Masa, and nothing existing ever leaks to a
2122
+ // different logged-in user.
2123
+ export function goalVisibleTo(goal, identity, ownerEmail) {
2124
+ const who = ownerEmail && identity === ownerEmail ? 'owner' : identity;
2125
+ if (who === 'owner') return true;
2126
+ return (goal?.owner ?? 'owner') === who;
2127
+ }
2128
+
2129
+
2130
+ // A `high` category means "never auto-approve"; `low` is surfaced but does not
2131
+ // force a pause. Matching is deliberately broad — over-flagging a sensitive
2132
+ // area (an extra human glance) is the safe failure mode here.
2133
+ export const CHANGE_RISK_RULES = [
2134
+ {
2135
+ id: 'secrets', label: 'Secrets / credentials', level: 'high',
2136
+ filePattern: /(^|\/)\.env(\.|$)|secret|credential|\.pem$|\.key$|id_rsa|api[_-]?key|\.pfx$|\.keystore$/i,
2137
+ // Added line that assigns a long literal to a secret-shaped name.
2138
+ diffPattern: /(?:^|\n)\+.*(?:api[_-]?key|secret|password|token|access[_-]?key)\s*[:=]\s*['"][^'"\s]{8,}/i,
2139
+ reason: 'Touches credentials or hardcodes a secret — never auto-approve.',
2140
+ },
2141
+ {
2142
+ id: 'auth', label: 'Auth / access control', level: 'high',
2143
+ filePattern: /(^|\/)(auth|login|logout|session|oauth|jwt|permission|rbac|acl|middleware)(\/|\.|$)|password|passport/i,
2144
+ reason: 'Changes authentication or access control.',
2145
+ },
2146
+ {
2147
+ id: 'payment', label: 'Payment / billing', level: 'high',
2148
+ filePattern: /payment|billing|stripe|checkout|charge|invoice|subscription|paypal|braintree/i,
2149
+ reason: 'Changes payment or billing logic.',
2150
+ },
2151
+ {
2152
+ id: 'db-migration', label: 'Database migration / schema', level: 'high',
2153
+ filePattern: /(^|\/)migrations?(\/|$)|migrat|schema\.(sql|prisma|rb)$|\.sql$|alembic|knexfile|prisma\/schema/i,
2154
+ diffPattern: /\b(DROP\s+(TABLE|DATABASE|COLUMN)|TRUNCATE\s+TABLE|ALTER\s+TABLE\s+\w+\s+DROP|DELETE\s+FROM)\b/i,
2155
+ reason: 'Alters DB schema or migrations — can be irreversible.',
2156
+ },
2157
+ {
2158
+ id: 'destructive', label: 'Destructive command', level: 'high',
2159
+ filePattern: null,
2160
+ // Only an ADDED destructive line counts (removing one is safe).
2161
+ diffPattern: /(?:^|\n)\+.*(rm\s+-rf|git\s+push\s+[^\n]*--force|--force\b|DROP\s+DATABASE|mkfs|dd\s+if=|>\s*\/dev\/sd)/i,
2162
+ reason: 'Adds a destructive or force command.',
2163
+ },
2164
+ {
2165
+ id: 'ci-deploy', label: 'CI / deploy config', level: 'low',
2166
+ filePattern: /(^|\/)\.github\/workflows\/|Dockerfile|docker-compose|vercel\.json|wrangler\.(toml|jsonc?)|(^|\/)\.circleci\/|Procfile|fly\.toml|netlify\.toml|(^|\/)deploy/i,
2167
+ reason: 'Changes CI/CD or deployment config.',
2168
+ },
2169
+ ];
2170
+
2171
+ // → { level:'none'|'low'|'high', shouldPause, categories:[{id,label,level,reason,files,viaDiff}], sensitiveFiles }
2172
+ export function classifyChangeRisk(changedFiles, diffText = '', { rules = CHANGE_RISK_RULES } = {}) {
2173
+ const files = (Array.isArray(changedFiles) ? changedFiles : [changedFiles])
2174
+ .filter((f) => typeof f === 'string' && f.trim())
2175
+ .map((f) => f.trim());
2176
+ const diff = typeof diffText === 'string' ? diffText : '';
2177
+ const categories = [];
2178
+ for (const rule of rules) {
2179
+ const matchedFiles = rule.filePattern ? files.filter((f) => rule.filePattern.test(f)) : [];
2180
+ const viaDiff = !!(rule.diffPattern && diff && rule.diffPattern.test(diff));
2181
+ if (matchedFiles.length === 0 && !viaDiff) continue;
2182
+ categories.push({ id: rule.id, label: rule.label, level: rule.level, reason: rule.reason, files: matchedFiles, viaDiff });
2183
+ }
2184
+ const hasHigh = categories.some((c) => c.level === 'high');
2185
+ const level = hasHigh ? 'high' : categories.length ? 'low' : 'none';
2186
+ const sensitiveFiles = [...new Set(categories.flatMap((c) => c.files))];
2187
+ return { level, shouldPause: hasHigh, categories, sensitiveFiles };
2188
+ }
2189
+
2190
+ // ---------------------------------------------------------------------------
2191
+ // goal 427: token/quota-frugal guardrails (PRD §4/§6 — "wake up to evidence,
2192
+ // not a burned weekly quota"). Three pure decision helpers; the stateful
2193
+ // wiring (accumulating elapsed/tokens/attempts per goal, detecting the
2194
+ // worker's actual stdout/stderr, pacing the queue) lives in server.mjs.
2195
+ // ---------------------------------------------------------------------------
2196
+
2197
+ // Sum the four token-count fields of a runClaude() usage object (the same
2198
+ // shape sumUsage() produces) into one raw number. cost_usd/duration_ms are not
2199
+ // "tokens" and excluded.
2200
+ export function usageTotalTokens(usage) {
2201
+ if (!usage) return 0;
2202
+ const u = usage;
2203
+ return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
2204
+ }
2205
+
2206
+ // Budget pressure is deliberately cache-aware: cache reads can be enormous
2207
+ // when a long Claude Code session is resumed, but they are not the same signal
2208
+ // as fresh input/output or cache creation. Count cache reads with a small
2209
+ // configurable weight so the guardrail catches real runaway work without
2210
+ // blocking every large-session continuation as if it spent that whole context
2211
+ // from scratch.
2212
+ export function usageBudgetTokens(usage, { cacheReadWeight = 0.1 } = {}) {
2213
+ if (!usage) return 0;
2214
+ const u = usage;
2215
+ const weight = Number.isFinite(Number(cacheReadWeight)) ? Math.max(0, Number(cacheReadWeight)) : 0.1;
2216
+ return Math.round(
2217
+ (u.input_tokens ?? 0)
2218
+ + (u.output_tokens ?? 0)
2219
+ + (u.cache_creation_input_tokens ?? 0)
2220
+ + ((u.cache_read_input_tokens ?? 0) * weight),
2221
+ );
2222
+ }
2223
+
2224
+ // Per-goal run budget (task 427 §1): once ANY of runtime / tokens / attempts
2225
+ // exceeds its limit, the goal must stop cleanly rather than keep retrying —
2226
+ // that's the actual quota burn. `limits` values of null/undefined disable
2227
+ // that particular check (so a caller can budget only what it wants to).
2228
+ // Checked in a fixed order (runtime, then tokens, then attempts) so a single
2229
+ // call always reports exactly one `which` even if several are already over.
2230
+ export function checkRunBudget({ elapsedMs = 0, tokens = 0, attempts = 0 } = {}, limits = {}) {
2231
+ const { maxRuntimeMs, maxTokens, maxAttempts } = limits;
2232
+ if (maxRuntimeMs != null && elapsedMs > maxRuntimeMs) {
2233
+ return { exceeded: true, which: 'runtime', reason: `run budget exceeded: runtime (${Math.round(elapsedMs / 60000)}min > ${Math.round(maxRuntimeMs / 60000)}min limit)` };
2234
+ }
2235
+ if (maxTokens != null && tokens > maxTokens) {
2236
+ return { exceeded: true, which: 'tokens', reason: `run budget exceeded: tokens (${tokens.toLocaleString('en-US')} > ${maxTokens.toLocaleString('en-US')} limit)` };
2237
+ }
2238
+ if (maxAttempts != null && attempts > maxAttempts) {
2239
+ return { exceeded: true, which: 'attempts', reason: `run budget exceeded: attempts (${attempts} > ${maxAttempts} limit)` };
2240
+ }
2241
+ return { exceeded: false, which: null, reason: null };
2242
+ }
2243
+
2244
+ // Rate-limit-aware pause/resume (task 427 §2): detect the `claude` worker
2245
+ // telling us it hit Claude's own usage/rate limit, from whatever text ended
2246
+ // up in the runClaude() result (the stream-json "result" event's text, or —
2247
+ // on a hard non-zero exit with no result — the captured stderr tail that
2248
+ // runClaude() already falls back to). Deliberately broad / best-effort: the
2249
+ // CLI's exact wording isn't a committed API and may drift, so this errs
2250
+ // toward over-matching (treating an ambiguous message as a rate limit and
2251
+ // pausing costs a little latency; missing one and hammering the limit costs
2252
+ // quota — the asymmetric-safe choice is over-matching).
2253
+ const RATE_LIMIT_RE = /(rate[\s-]?limit|usage limit|quota\s*(exceeded|reached)|resets?\s+(at|in)\b|try again (in|later)|5[\s-]hour limit|weekly limit|claude ai usage limit)/i;
2254
+ export function isRateLimited(resultText) {
2255
+ return RATE_LIMIT_RE.test(String(resultText ?? ''));
2256
+ }
2257
+
2258
+ // Backoff before an automatic resume, keyed by how many times THIS goal has
2259
+ // already been rate-limited (not the task's normal verify-retry `attempt` —
2260
+ // callers pass their own rate-limit-hit counter). Exponential, capped, so a
2261
+ // goal stuck against a still-active limit doesn't hot-loop retries into the
2262
+ // same wall (defeats the whole point) but also doesn't wait needlessly long
2263
+ // once the limit has plausibly cleared.
2264
+ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 * 60 * 1000 } = {}) {
2265
+ const n = Math.max(1, Number(attempt) || 1);
2266
+ return Math.min(maxMs, baseMs * 2 ** (n - 1));
2267
+ }
2268
+
2269
+ // ---- billing entitlement gate (public self-serve launch, docs/BILLING-LAUNCH-PLAN.md) --
2270
+ // Mirrors workers/billing-api/src/entitlement.mjs's FREE_TIER_LIMITS/
2271
+ // checkFreeTierLimit/resolveEntitlement exactly (Masa decision 2026-07-12:
2272
+ // 5 pending goals, 19 lifetime goals, 1 project, $16/mo unlocks all three).
2273
+ // Deliberately duplicated rather than imported: engine/ and workers/billing-api/
2274
+ // are independently deployed (a user's Mac vs Cloudflare) and independently
2275
+ // tested — sharing a module across that boundary would couple two things
2276
+ // that fail and deploy on different schedules. Keep the two copies in sync by
2277
+ // hand; both have their own test coverage against the same free-tier numbers.
2278
+ export const FREE_TIER_LIMITS = Object.freeze({
2279
+ maxPendingGoals: 5,
2280
+ maxCumulativeGoals: 19,
2281
+ maxProjects: 1,
2282
+ });
2283
+
2284
+ export function checkFreeTierLimit(usage) {
2285
+ const pendingCount = Number(usage?.pendingCount) || 0;
2286
+ const cumulativeCount = Number(usage?.cumulativeCount) || 0;
2287
+ const projectCount = Number(usage?.projectCount) || 0;
2288
+ if (pendingCount > FREE_TIER_LIMITS.maxPendingGoals) {
2289
+ return { blocked: true, reason: 'pending-goal-limit', limit: FREE_TIER_LIMITS.maxPendingGoals, value: pendingCount };
2290
+ }
2291
+ if (cumulativeCount > FREE_TIER_LIMITS.maxCumulativeGoals) {
2292
+ return { blocked: true, reason: 'cumulative-goal-limit', limit: FREE_TIER_LIMITS.maxCumulativeGoals, value: cumulativeCount };
2293
+ }
2294
+ if (projectCount > FREE_TIER_LIMITS.maxProjects) {
2295
+ return { blocked: true, reason: 'project-limit', limit: FREE_TIER_LIMITS.maxProjects, value: projectCount };
2296
+ }
2297
+ return { blocked: false, reason: null, limit: null, value: null };
2298
+ }
2299
+
2300
+ export function resolveEntitlement({ isOwner, isPaying, usage }) {
2301
+ if (isOwner) return { allowed: true, blocked: null };
2302
+ if (isPaying) return { allowed: true, blocked: null };
2303
+ const limit = checkFreeTierLimit(usage);
2304
+ return { allowed: !limit.blocked, blocked: limit.blocked ? limit : null };
2305
+ }
2306
+
2307
+ // Design principle (BILLING-LAUNCH-PLAN.md §2): billing-api being briefly
2308
+ // unreachable must not lock out a previously-confirmed paying user. `cached`
2309
+ // is `{isPaying, checkedAt}` or null (never checked). Within `freshMs` the
2310
+ // cache is used with no network call at all (avoids a round trip on every
2311
+ // over-the-free-tier request); within `graceMs` a stale cache is still
2312
+ // trusted (covers a transient billing-api outage) but a refresh is signaled;
2313
+ // beyond `graceMs`, or with no cache at all, there is nothing safe to trust
2314
+ // so it resolves to not-paying (fail closed — never invent a "yes" for an
2315
+ // identity we have no evidence paid).
2316
+ export function resolveCachedEntitlement({ cached = null, now = Date.now(), freshMs = 10 * 60 * 1000, graceMs = 24 * 60 * 60 * 1000 } = {}) {
2317
+ if (!cached) return { status: 'unknown', isPaying: false, needsRefresh: true };
2318
+ const age = now - (Number(cached.checkedAt) || 0);
2319
+ if (age <= freshMs) return { status: 'fresh', isPaying: Boolean(cached.isPaying), needsRefresh: false };
2320
+ if (age <= graceMs) return { status: 'stale-grace', isPaying: Boolean(cached.isPaying), needsRefresh: true };
2321
+ return { status: 'expired', isPaying: false, needsRefresh: true };
2322
+ }
2323
+
2324
+ // ---- license token (local-first identity, docs/BILLING-LAUNCH-PLAN.md) --
2325
+ // Verifies the Ed25519-signed license token billing-api issues after a real
2326
+ // Stripe payment (workers/billing-api/src/license.mjs signs it; this is the
2327
+ // verify-only half, since the local app never signs). Offline: no network
2328
+ // call, works even if billing-api is unreachable — the token proves WHO the
2329
+ // user is (their paid email), independent of whether isPaying is still true
2330
+ // right now (that freshness check is resolveCachedEntitlement's job, backed
2331
+ // by a live GET /entitlement). Uses only Web Crypto (crypto.subtle) +
2332
+ // btoa/atob, available in both Node 20+ and the Workers runtime, so this is
2333
+ // byte-for-byte the same algorithm as the signer.
2334
+ //
2335
+ // LICENSE_PUBLIC_JWK is the public half of the keypair minted 2026-07-12
2336
+ // (workers/billing-api/scripts/gen-license-key.mjs) and deployed as billing-api's
2337
+ // LICENSE_PUBLIC_JWK var — public, safe to commit; rotate both sides together.
2338
+ export const LICENSE_PUBLIC_JWK = { key_ops: ['verify'], ext: true, alg: 'Ed25519', crv: 'Ed25519', x: 'Pfhl9xhWpyo0Lc3TgZMxF2P8P145B8zRyIAKFKFflaM', kty: 'OKP' };
2339
+
2340
+ const licenseEnc = new TextEncoder();
2341
+ const licenseDec = new TextDecoder();
2342
+ function licenseB64urlToBytes(str) {
2343
+ const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4);
2344
+ const bin = atob(b64);
2345
+ const out = new Uint8Array(bin.length);
2346
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
2347
+ return out;
2348
+ }
2349
+ const licenseB64urlToString = (s) => licenseDec.decode(licenseB64urlToBytes(s));
2350
+
2351
+ // Returns { valid, reason, payload }. `payload` ({email, isPaying, plan, iat,
2352
+ // exp}) is returned even when expired so the caller can show the stale
2353
+ // email/expiry; it is null when the token can't be decoded or the signature
2354
+ // is bad.
2355
+ export async function verifyLicenseToken({ token, publicJwk = LICENSE_PUBLIC_JWK, now = Date.now() } = {}) {
2356
+ if (!token || typeof token !== 'string') return { valid: false, reason: 'missing token', payload: null };
2357
+ const parts = token.split('.');
2358
+ if (parts.length !== 3) return { valid: false, reason: 'malformed token', payload: null };
2359
+ const [h, p, s] = parts;
2360
+ let header, payload;
2361
+ try { header = JSON.parse(licenseB64urlToString(h)); payload = JSON.parse(licenseB64urlToString(p)); }
2362
+ catch { return { valid: false, reason: 'undecodable token', payload: null }; }
2363
+ if (header?.typ !== 'AGLD-LIC') return { valid: false, reason: 'wrong token type', payload: null };
2364
+ let ok;
2365
+ try {
2366
+ // Strip `alg` — some Web Crypto runtimes reject an OKP JWK with alg:"Ed25519"
2367
+ // (they expect the JWA name "EdDSA"/none). Node is lenient; do it anyway so
2368
+ // the same token verifies identically in every runtime. See license.mjs.
2369
+ const { alg: _drop, ...pub } = publicJwk;
2370
+ const key = await crypto.subtle.importKey('jwk', pub, { name: 'Ed25519' }, false, ['verify']);
2371
+ ok = await crypto.subtle.verify({ name: 'Ed25519' }, key, licenseB64urlToBytes(s), licenseEnc.encode(`${h}.${p}`));
2372
+ } catch { return { valid: false, reason: 'verify error', payload: null }; }
2373
+ if (!ok) return { valid: false, reason: 'bad signature', payload: null };
2374
+ if (typeof payload?.exp === 'number' && Math.floor(now / 1000) > payload.exp) {
2375
+ return { valid: false, reason: 'expired', payload };
2376
+ }
2377
+ return { valid: true, reason: null, payload };
2378
+ }