@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.
- package/CLAUDE.md +44 -0
- package/README.md +83 -0
- package/app/fonts.css +8 -0
- package/app/index.html +7638 -0
- package/app/theme.css +126 -0
- package/app/wp/w1.jpg +0 -0
- package/app/wp/w2.jpg +0 -0
- package/bin/manager-for-ai.mjs +76 -0
- package/engine/lib.mjs +2378 -0
- package/engine/manager.mjs +115 -0
- package/engine/mcp.mjs +123 -0
- package/engine/pr.mjs +144 -0
- package/engine/relay-client.mjs +82 -0
- package/engine/server.mjs +3315 -0
- package/engine/verify.mjs +158 -0
- package/examples/task-001/runs/2026-07-03T06-23-57-441Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T06-23-57-441Z/report.json +20 -0
- package/examples/task-001/runs/2026-07-03T06-29-54-517Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T06-29-54-517Z/report.json +20 -0
- package/examples/task-001/runs/2026-07-03T06-40-23-231Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T06-40-23-231Z/report.json +20 -0
- package/examples/task-001/runs/2026-07-03T07-34-58-109Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T07-34-58-109Z/report.json +21 -0
- package/examples/task-001/runs/2026-07-03T07-53-44-639Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T07-53-44-639Z/report.json +21 -0
- package/examples/task-001/runs/2026-07-03T08-02-12-117Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T08-02-12-117Z/report.json +21 -0
- package/examples/task-001/task.json +11 -0
- package/examples/task-001/verify.mjs +16 -0
- package/examples/toast-app/app.js +23 -0
- package/examples/toast-app/index.html +28 -0
- package/examples/toast-app/test/guard.test.mjs +83 -0
- package/examples/toast-app/test/style.test.mjs +19 -0
- 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\n` : '',
|
|
706
|
+
it.beforeShotName ? `After / 変更後:\n` : '',
|
|
707
|
+
it.gifName ? `\n` : '',
|
|
708
|
+
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\n` : '',
|
|
1257
|
+
`## Screenshot / スクリーンショット`,
|
|
1258
|
+
``,
|
|
1259
|
+
``,
|
|
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
|
+
}
|