@galda/cli 0.10.38 → 0.10.40
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 +7 -3
- package/app/index.html +511 -342
- package/engine/lib.mjs +450 -37
- package/engine/server.mjs +274 -279
- package/engine/shot.mjs +96 -0
- package/package.json +1 -1
package/engine/lib.mjs
CHANGED
|
@@ -534,6 +534,96 @@ export function parseStreamEvents(line) {
|
|
|
534
534
|
return out;
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
// ---- CODEX BRIDGE ----------------------------------------------------------
|
|
538
|
+
// The same three wires the product is built out of — the board (the agent's own
|
|
539
|
+
// to-dos), the implementation log (what it actually did), and the thread it can
|
|
540
|
+
// be talked to again in — read from `codex exec --json` instead of `claude -p
|
|
541
|
+
// --output-format stream-json`. Everything above this line stays agent-agnostic:
|
|
542
|
+
// the bridge's whole job is to speak Codex's event vocabulary into the one this
|
|
543
|
+
// engine already has, so nothing downstream branches on which agent ran.
|
|
544
|
+
//
|
|
545
|
+
// The shapes below are transcribed from a real `codex exec --json` run
|
|
546
|
+
// (codex-cli 0.142.4) — see engine/test/codex-bridge.test.mjs, whose fixtures
|
|
547
|
+
// are that run's stdout verbatim rather than a schema we imagined.
|
|
548
|
+
export function parseCodexTodos(items) {
|
|
549
|
+
return (Array.isArray(items) ? items : [])
|
|
550
|
+
.map((i) => ({ content: String(i?.text ?? '').trim(), status: i?.completed ? 'completed' : 'pending' }))
|
|
551
|
+
.filter((t) => t.content);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Codex reports usage with its own key names AND its own arithmetic: its
|
|
555
|
+
// `input_tokens` INCLUDES the cached ones, where claude reports cache reads as a
|
|
556
|
+
// separate bucket that input_tokens excludes. Adding both as-is counted the
|
|
557
|
+
// cached tokens twice, so the ledger showed a Codex run as far more expensive
|
|
558
|
+
// than it was (measured: ~92k of a ~108k turn was cache). Subtract, so a number
|
|
559
|
+
// from either agent means the same thing.
|
|
560
|
+
export function parseCodexUsage(usage) {
|
|
561
|
+
if (!usage) return null;
|
|
562
|
+
const total = usage.input_tokens ?? usage.inputTokens ?? 0;
|
|
563
|
+
const cached = usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0;
|
|
564
|
+
return {
|
|
565
|
+
input_tokens: Math.max(0, total - cached),
|
|
566
|
+
output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
|
|
567
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
|
568
|
+
cache_read_input_tokens: cached,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Parse one line of `codex exec --json` output into the same event vocabulary
|
|
573
|
+
// parseStreamEvents returns ('session' | 'todos' | 'activity' | 'message' |
|
|
574
|
+
// 'usage'). 'message' rather than 'result' because Codex emits an agent_message
|
|
575
|
+
// per narration step, not once at the end — the caller keeps the last one.
|
|
576
|
+
export function parseCodexEvents(line) {
|
|
577
|
+
let ev;
|
|
578
|
+
try { ev = JSON.parse(line); } catch { return []; }
|
|
579
|
+
if (!ev || typeof ev !== 'object') return [];
|
|
580
|
+
if (ev.type === 'thread.started' && ev.thread_id) return [{ kind: 'session', id: String(ev.thread_id) }];
|
|
581
|
+
if (ev.type === 'turn.completed') return [{ kind: 'usage', usage: parseCodexUsage(ev.usage) }];
|
|
582
|
+
if (ev.type === 'error' && ev.message) return [{ kind: 'error', text: String(ev.message) }];
|
|
583
|
+
if (!['item.started', 'item.updated', 'item.completed'].includes(ev.type)) return [];
|
|
584
|
+
const item = ev.item ?? {};
|
|
585
|
+
if (item.type === 'todo_list') {
|
|
586
|
+
const todos = parseCodexTodos(item.items);
|
|
587
|
+
return todos.length ? [{ kind: 'todos', todos }] : [];
|
|
588
|
+
}
|
|
589
|
+
if (item.type === 'agent_message' && ev.type === 'item.completed' && item.text) {
|
|
590
|
+
return [{ kind: 'message', text: String(item.text) }];
|
|
591
|
+
}
|
|
592
|
+
// Commands are announced when they start (the log should move while the
|
|
593
|
+
// worker works, not after) and only mentioned again when they fail.
|
|
594
|
+
if (item.type === 'command_execution' && item.command) {
|
|
595
|
+
if (ev.type === 'item.started') return [{ kind: 'activity', text: `Run ${String(item.command)}`.slice(0, 140) }];
|
|
596
|
+
if (ev.type === 'item.completed' && item.exit_code) {
|
|
597
|
+
return [{ kind: 'activity', text: `Run ${String(item.command)} (exit ${item.exit_code})`.slice(0, 140) }];
|
|
598
|
+
}
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
// File edits are the bulk of what a worker does and were entirely missing from
|
|
602
|
+
// the log on Codex — the run read as "a few commands and then, somehow, a PR".
|
|
603
|
+
if (item.type === 'file_change' && ev.type === 'item.completed') {
|
|
604
|
+
const verb = { add: 'Write', delete: 'Delete' };
|
|
605
|
+
return (Array.isArray(item.changes) ? item.changes : [])
|
|
606
|
+
.filter((c) => c?.path)
|
|
607
|
+
.slice(0, 8)
|
|
608
|
+
.map((c) => ({ kind: 'activity', text: `${verb[c.kind] ?? 'Edit'} ${String(c.path)}`.slice(0, 140) }));
|
|
609
|
+
}
|
|
610
|
+
if (item.type === 'error' && item.message && ev.type === 'item.completed') {
|
|
611
|
+
return [{ kind: 'error', text: String(item.message) }];
|
|
612
|
+
}
|
|
613
|
+
return [];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Argument list for one `codex exec` run. `resume` continues an existing thread
|
|
617
|
+
// (the id from a previous run's thread.started) — that subcommand takes neither
|
|
618
|
+
// --cd nor --sandbox, so the working directory comes from the spawn's cwd and
|
|
619
|
+
// the sandbox from -c sandbox_mode. Verified against codex-cli 0.142.4.
|
|
620
|
+
export function buildCodexArgs({ model, effort, sandbox, cwd, resume } = {}) {
|
|
621
|
+
const common = ['--json', '-m', model, '-c', `model_reasoning_effort="${effort}"`, '--skip-git-repo-check'];
|
|
622
|
+
return resume
|
|
623
|
+
? ['exec', 'resume', String(resume), ...common, '-c', `sandbox_mode="${sandbox}"`, '-']
|
|
624
|
+
: ['exec', ...common, '--sandbox', sandbox, '--cd', cwd, '-'];
|
|
625
|
+
}
|
|
626
|
+
|
|
537
627
|
// Parse the planner's reply into { entry, tasks }. Accepts either a JSON
|
|
538
628
|
// object {entry, tasks:[...]} or a bare JSON array anywhere in the text;
|
|
539
629
|
// falls back to a single task made from the raw request. Each task may carry
|
|
@@ -590,6 +680,40 @@ export function isEngineeringPhaseTitle(title) {
|
|
|
590
680
|
return ENGINEERING_PHASE_RE.test(String(title ?? ''));
|
|
591
681
|
}
|
|
592
682
|
|
|
683
|
+
// ---- title = the user's REQUEST, not a completion report -------------------
|
|
684
|
+
// Masa (goal #474): the planner echoed a worker's finished-work sentence as the
|
|
685
|
+
// title —「アニメーションが終わってから歯車の位置を計算するように直した」— instead of
|
|
686
|
+
// naming what the user asked for. A title should read like the ask ("…のバグ修正"),
|
|
687
|
+
// never like a past-tense report of HOW it was implemented. Detect that shape so
|
|
688
|
+
// we can fall back to the user's own words. NARROW on purpose: only past-tense
|
|
689
|
+
// completion phrasing (…した/…ました, "…ように直/修/変") — a genuine request title
|
|
690
|
+
// never ends that way.
|
|
691
|
+
const IMPL_REPORT_RE = /(直し|修正し|変更し|追加し|対応し|実装し|計算する|するように|完了し)(た|ました)?/;
|
|
692
|
+
const PAST_DONE_RE = /(直した|修正した|変更した|追加した|対応した|実装した|しました|ました|するように直|するように修正|するように変更)/;
|
|
693
|
+
export function isImplementationReportTitle(title) {
|
|
694
|
+
const s = String(title ?? '').trim();
|
|
695
|
+
if (!s) return false;
|
|
696
|
+
// Report shape = past-tense completion OR "…するように<動詞>" (describes the how).
|
|
697
|
+
return PAST_DONE_RE.test(s) || (/するように/.test(s) && IMPL_REPORT_RE.test(s));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Derive a short, user-worded title from the raw request text. Strips the polite
|
|
701
|
+
// request tail (…して/してください/直して/お願いします 等) so the title reads as the
|
|
702
|
+
// thing itself (「…バグ修正して」→「…バグ修正」) — the user's words, not a rephrase.
|
|
703
|
+
export function titleFromRequest(goalText, max = 60) {
|
|
704
|
+
let s = String(goalText ?? '').replace(/\s+/g, ' ').trim();
|
|
705
|
+
if (!s) return '';
|
|
706
|
+
// Drop a trailing polite/imperative request tail so the noun-phrase remains.
|
|
707
|
+
// Order matters: strip the courtesy suffix first, then the verb 直す ("…を直して"
|
|
708
|
+
// is a verb — drop the whole thing), then the する light verb ("…修正して" keeps
|
|
709
|
+
// the noun 修正, drops only して).
|
|
710
|
+
s = s.replace(/[\s、。,.,.!!??]*(?:お願いします|おねがいします|お願い|please)[。.!!]*$/iu, '')
|
|
711
|
+
.replace(/[\s、。,.,.!!??]*(?:を)?直して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
|
|
712
|
+
.replace(/[\s、。,.,.!!??]*して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
|
|
713
|
+
.trim();
|
|
714
|
+
return s.slice(0, max);
|
|
715
|
+
}
|
|
716
|
+
|
|
593
717
|
// Reduce a planner's task list to USER-TOPIC granularity — the deterministic
|
|
594
718
|
// backstop for when the LLM over-decomposes despite the prompt. Two moves, both
|
|
595
719
|
// conservative so a distinct ask is never silently dropped:
|
|
@@ -603,7 +727,13 @@ export function isEngineeringPhaseTitle(title) {
|
|
|
603
727
|
// worker still owns the full engineering breakdown internally.
|
|
604
728
|
export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
|
|
605
729
|
const list = (tasks ?? []).filter((t) => t && String(t.title ?? '').trim());
|
|
606
|
-
|
|
730
|
+
// A title that reads as a completion report ("…するように直した") is replaced with
|
|
731
|
+
// the user's own request wording. Only safe when there's a single topic — with
|
|
732
|
+
// multiple distinct deliverables the goal text can't stand in for any one title.
|
|
733
|
+
const userTitled = (arr) => arr.length === 1 && isImplementationReportTitle(arr[0].title) && titleFromRequest(goalText)
|
|
734
|
+
? [{ ...arr[0], title: titleFromRequest(goalText) }]
|
|
735
|
+
: arr;
|
|
736
|
+
if (list.length <= 1) return userTitled(list.slice(0, maxTasks));
|
|
607
737
|
const real = list.filter((t) => !isEngineeringPhaseTitle(t.title));
|
|
608
738
|
if (!real.length) {
|
|
609
739
|
const title = String(goalText ?? list[0].title).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
@@ -611,7 +741,7 @@ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
|
|
|
611
741
|
const passCondition = list.find((t) => t.passCondition)?.passCondition ?? null;
|
|
612
742
|
return [{ title, detail, passCondition }];
|
|
613
743
|
}
|
|
614
|
-
return real.slice(0, maxTasks);
|
|
744
|
+
return userTitled(real.slice(0, maxTasks));
|
|
615
745
|
}
|
|
616
746
|
|
|
617
747
|
// ---- dedupe / merge overlapping goals --------------------------------------
|
|
@@ -870,8 +1000,30 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
|
|
|
870
1000
|
L(`Implemented by Manager for AI: the goal was decomposed into ${goalTasks.length} task(s), each executed by a Claude Code worker (flat-rate, no extra API), tests written and run per task.${proofItemsCount ? ' Tasks with a pass condition were verified independently in a headless browser.' : ''}`,
|
|
871
1001
|
`Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItemsCount ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
|
|
872
1002
|
'',
|
|
1003
|
+
// How the reviewer sees the thing for themselves, in the worker's own
|
|
1004
|
+
// words. Above the reports because "run it and look" beats reading about
|
|
1005
|
+
// it — and because for a request with nothing to run this section is simply
|
|
1006
|
+
// absent rather than filled with a staged screenshot.
|
|
1007
|
+
...(() => {
|
|
1008
|
+
const runs = goalTasks.filter((t) => t.run && (t.run.cmd || t.run.url));
|
|
1009
|
+
if (!runs.length) return [];
|
|
1010
|
+
return [
|
|
1011
|
+
'',
|
|
1012
|
+
L('## See it running', '## 動かして見る'),
|
|
1013
|
+
'',
|
|
1014
|
+
...runs.flatMap((t) => [
|
|
1015
|
+
t.run.note ? `${t.run.note}` : '',
|
|
1016
|
+
t.run.cmd ? '```\n' + t.run.cmd + '\n```' : '',
|
|
1017
|
+
t.run.url ? `→ ${t.run.url}` : '',
|
|
1018
|
+
].filter(Boolean)),
|
|
1019
|
+
];
|
|
1020
|
+
})(),
|
|
1021
|
+
'',
|
|
873
1022
|
L('## Tasks and worker reports', '## タスクとworker報告'),
|
|
874
|
-
|
|
1023
|
+
// 800 chars used to cut the worker's report mid-sentence. For a request
|
|
1024
|
+
// that produces no runnable thing (research, a summary, an answer) that
|
|
1025
|
+
// report IS the deliverable, so truncating it threw away the result.
|
|
1026
|
+
...goalTasks.map((t) => `\n### ${t.num} ${t.title} — ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 8000)}`),
|
|
875
1027
|
'',
|
|
876
1028
|
L(`Changed files: ${files.map((f) => `\`${f}\``).join(' ')}`, `変更ファイル: ${files.map((f) => `\`${f}\``).join(' ')}`),
|
|
877
1029
|
].join('\n');
|
|
@@ -913,28 +1065,6 @@ export function parseReviewSummary(raw, fallback = {}) {
|
|
|
913
1065
|
};
|
|
914
1066
|
}
|
|
915
1067
|
|
|
916
|
-
// A finished task's changed files look like a UI edit: app/ (the Manager's
|
|
917
|
-
// own UI dir, or an equivalent frontend root) plus common frontend source
|
|
918
|
-
// extensions. Pure/deterministic — no LLM — so it can gate proof capture
|
|
919
|
-
// for EVERY UI-touching goal, not just ones that happened to get a
|
|
920
|
-
// passCondition (Masa dogfood: "見た目の調整" goals had no capture at all).
|
|
921
|
-
const UI_TOUCH_RE = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
|
|
922
|
-
export function isUiChange(changedFiles) {
|
|
923
|
-
return (changedFiles ?? []).some((f) => UI_TOUCH_RE.test(String(f ?? '')));
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// Whether a finished UI-touching task/goal needs a zero-LLM proof capture
|
|
927
|
-
// (screenshot + short clip of the ephemeral branch instance): only when the
|
|
928
|
-
// project can actually be booted & screenshotted (canCapture), nothing has
|
|
929
|
-
// captured proof yet (hasProof), and the change really looks like UI
|
|
930
|
-
// (isUiChange). This is the ADDED path for goals with no explicit
|
|
931
|
-
// passCondition — the existing passCondition-based verify loop (runVerification
|
|
932
|
-
// in verify.mjs) still runs and still asserts pass/fail; this only decides
|
|
933
|
-
// whether to ALSO take a capture-only snapshot when that loop didn't run.
|
|
934
|
-
export function shouldCaptureProof({ changedFiles, hasProof, canCapture }) {
|
|
935
|
-
return Boolean(canCapture) && !hasProof && isUiChange(changedFiles);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
1068
|
const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
|
|
939
1069
|
export function hasTestRelevantChanges(changedFiles) {
|
|
940
1070
|
const files = (changedFiles ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
|
|
@@ -1009,17 +1139,25 @@ export function classifyVerifyGate({
|
|
|
1009
1139
|
return { blocked, kind, proofAdvisory };
|
|
1010
1140
|
}
|
|
1011
1141
|
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1142
|
+
// Serializes async work so at most one item runs at a time. The goal-verify
|
|
1143
|
+
// pipeline uses this to stop two goals' `npm test` runs (each spins up real
|
|
1144
|
+
// headless Chrome + real servers) from ever overlapping — running two full
|
|
1145
|
+
// suites at once was the actual cause of goal #552/#487 each blocking on a
|
|
1146
|
+
// DIFFERENT flaky test on every retry (2026-07-21 postmortem): the requested
|
|
1147
|
+
// feature was already correct, but the shared machine's CPU/ports/tmp-dirs
|
|
1148
|
+
// couldn't support two concurrent suites, so the baseline-diff gate never
|
|
1149
|
+
// saw the same failure twice in a row and never converged to "no new
|
|
1150
|
+
// failures". Queued work still runs even if an earlier item throws/rejects —
|
|
1151
|
+
// one bad run must not wedge every goal behind it forever.
|
|
1152
|
+
export function createSerialQueue() {
|
|
1153
|
+
let tail = Promise.resolve();
|
|
1154
|
+
return {
|
|
1155
|
+
run(fn) {
|
|
1156
|
+
const started = tail.then(fn, fn);
|
|
1157
|
+
tail = started.then(() => undefined, () => undefined);
|
|
1158
|
+
return started;
|
|
1159
|
+
},
|
|
1160
|
+
};
|
|
1023
1161
|
}
|
|
1024
1162
|
|
|
1025
1163
|
// Task 56: a project's review definition can carry a free-text `description`
|
|
@@ -1179,6 +1317,20 @@ export function goalGroupStatus(goalStatus, startedAt) {
|
|
|
1179
1317
|
return 'done'; // includes 'reverted' (terminal)
|
|
1180
1318
|
}
|
|
1181
1319
|
|
|
1320
|
+
// PRD §6.5 → Masa 2026-07-21: a folded goal no longer takes its own row in the Review
|
|
1321
|
+
// column. The standalone "→ #12 にまとめました" rows read as clutter ("まとめましたは
|
|
1322
|
+
// review欄にいらない"); the merge belongs inside the carrier review's detail, as a log of
|
|
1323
|
+
// what was folded into it. This selects the folded goals for a given carrier review (a
|
|
1324
|
+
// review can group several goalIds under one PR, so match against the whole set). Pure
|
|
1325
|
+
// so the browser mirror in app/index.html (non-module, can't import this) and the test
|
|
1326
|
+
// agree on exactly which goals a carrier review logs.
|
|
1327
|
+
export function foldedIntoReview(goals, targetGoalIds) {
|
|
1328
|
+
const ids = new Set((targetGoalIds ?? []).map(Number));
|
|
1329
|
+
return (goals ?? []).filter(
|
|
1330
|
+
(g) => g && g.status === 'folded' && g.foldedInto != null && ids.has(Number(g.foldedInto)),
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1182
1334
|
// Label + CSS class for the small status chip shown next to a goal in the
|
|
1183
1335
|
// goal list and goal detail thread — mirrored in app/index.html's GOAL_CHIP
|
|
1184
1336
|
// since that inline script can't import this module.
|
|
@@ -2083,7 +2235,268 @@ const WORKER_BASH = [
|
|
|
2083
2235
|
'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
|
|
2084
2236
|
'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
|
|
2085
2237
|
].map((c) => `Bash(${c}:*)`).join(',');
|
|
2086
|
-
|
|
2238
|
+
// WebFetch/WebSearch: a plain Claude Code session has both, and a worker that
|
|
2239
|
+
// cannot open a page or look anything up fails a whole class of requests that
|
|
2240
|
+
// work locally ("この記事のとおりに直して", "現行の仕様を確認して"). They are
|
|
2241
|
+
// read-only, so they widen what the worker can SEE, not what it can change.
|
|
2242
|
+
export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,WebFetch,WebSearch,${WORKER_BASH}`;
|
|
2243
|
+
|
|
2244
|
+
// ---- goal → single task (demoted planner) ----------------------------------
|
|
2245
|
+
// P0b (Masa 2026-07-21): the planner no longer decomposes the request into
|
|
2246
|
+
// tasks. The request reaches the worker verbatim as ONE task; the worker
|
|
2247
|
+
// (Claude Code / Codex) decides its own breakdown and emits it as its own
|
|
2248
|
+
// TodoWrite, which the board surfaces. One card = one request.
|
|
2249
|
+
|
|
2250
|
+
// A short, single-line display label for the task, derived from the request
|
|
2251
|
+
// itself (no LLM rewrite). First non-empty line, trimmed and truncated.
|
|
2252
|
+
export function goalTaskTitle(text, max = 80) {
|
|
2253
|
+
const firstLine = String(text ?? '').split('\n').map((s) => s.trim()).find(Boolean) ?? '';
|
|
2254
|
+
return firstLine.length > max ? firstLine.slice(0, max - 1).trimEnd() + '…' : firstLine;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// Build the single task that carries a goal's request verbatim to the worker.
|
|
2258
|
+
// `id`, `num`, `createdAt` are supplied by the caller (stateful/impure bits);
|
|
2259
|
+
// everything else is derived from the goal so this stays pure & testable.
|
|
2260
|
+
// `passCondition` rides along only when the user wrote an explicit one.
|
|
2261
|
+
export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }) {
|
|
2262
|
+
return {
|
|
2263
|
+
id, num, goalId: goal.id, projectId: goal.projectId,
|
|
2264
|
+
title: goalTaskTitle(goal.text), detail: goal.text, passCondition,
|
|
2265
|
+
model: goal.model, effort: goal.effort, agent: goal.agent,
|
|
2266
|
+
mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
|
|
2267
|
+
status: 'queued', createdAt, priority: '中',
|
|
2268
|
+
result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// ---- worker prompt ---------------------------------------------------------
|
|
2273
|
+
// The instruction the worker (Claude Code / Codex) actually reads. Pure:
|
|
2274
|
+
// (task, goal, lastFailure, agentName) -> string, so it is unit-testable.
|
|
2275
|
+
// `agentName` is the resolved worker agent id ('codex' | 'claude-code'); the
|
|
2276
|
+
// caller resolves it (server keeps the WORKER_AGENTS table).
|
|
2277
|
+
//
|
|
2278
|
+
// P0a "un-lobotomy" (Masa 2026-07-21, goal #496): the harness used to (a) trim
|
|
2279
|
+
// the user's real request to 500 chars and bury it as "part of the overall
|
|
2280
|
+
// goal", and (b) inject suppressive RULES ("keep changes minimal", "make the
|
|
2281
|
+
// smallest safe change instead of expanding scope", "検証しすぎない…すぐ終える").
|
|
2282
|
+
// Together they turned "design 3 patterns as an artifact" into one minimal
|
|
2283
|
+
// tweak. North star: we never re-judge the request — the user's verbatim words
|
|
2284
|
+
// are the source of truth and the worker delivers exactly what they ask for.
|
|
2285
|
+
export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code') {
|
|
2286
|
+
// Optional skill the user attached to this goal. Empirically (scratch worker,
|
|
2287
|
+
// haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
|
|
2288
|
+
// instruction as the FIRST line; a bare "/name" first line did not consistently
|
|
2289
|
+
// trigger the skill in `claude -p`. See commit body for the run evidence.
|
|
2290
|
+
const skill = task?.skill ?? goal?.skill;
|
|
2291
|
+
const displayName = agentName === 'codex' ? 'Codex' : 'Claude Code';
|
|
2292
|
+
// Report back to the human in the SAME language they wrote the goal in (Masa
|
|
2293
|
+
// 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
|
|
2294
|
+
const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
|
|
2295
|
+
return [
|
|
2296
|
+
skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
|
|
2297
|
+
`You are a ${displayName} worker managed by "Manager for AI". Do the request below inside this project directory.`,
|
|
2298
|
+
goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
|
|
2299
|
+
'',
|
|
2300
|
+
// The user's own words, verbatim and in full, are the source of truth.
|
|
2301
|
+
// TASK/DETAIL below is a paraphrase from the planner — when they differ,
|
|
2302
|
+
// follow the user's words.
|
|
2303
|
+
goal?.text ? `USER'S REQUEST (verbatim — the source of truth; deliver exactly this):\n${goal.text}` : '',
|
|
2304
|
+
'',
|
|
2305
|
+
`TASK ${task.num}: ${task.title}`,
|
|
2306
|
+
task.detail ? `DETAIL: ${task.detail}` : '',
|
|
2307
|
+
goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
|
|
2308
|
+
task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
|
|
2309
|
+
lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
|
|
2310
|
+
'',
|
|
2311
|
+
'RULES:',
|
|
2312
|
+
// The board is a window onto the agent's own plan — it holds nothing this
|
|
2313
|
+
// layer invented. That only works if the agent keeps a plan where we can see
|
|
2314
|
+
// it. Claude Code reaches for TodoWrite on its own; Codex has the same list
|
|
2315
|
+
// but skips it on anything small (measured 2026-07-22: gpt-5.4-mini emitted
|
|
2316
|
+
// no todo_list even when the REQUEST said "in three steps" — it does emit one
|
|
2317
|
+
// when told to track the work in its to-do list). So ask, once, for every
|
|
2318
|
+
// agent — the instruction is about how to work, never about what the request
|
|
2319
|
+
// is, so no request-type branching enters here.
|
|
2320
|
+
'- 作業の手順は最初に自分の Todo リスト(Claude Code なら TodoWrite、Codex なら todo list)に書き出し、進むたびに更新すること。人はこの Todo を見て進み具合を知る。',
|
|
2321
|
+
'- Edit only files inside this directory and this project. Stay on this request; do not wander into unrelated work.',
|
|
2322
|
+
// Deliverable fidelity (replaces the old "make the smallest safe change
|
|
2323
|
+
// instead of expanding scope"): the request, not our fear of scope, sets
|
|
2324
|
+
// the size of the work.
|
|
2325
|
+
'- Deliver what the request actually asks for. If it asks for multiple options, N variations, a visual, an artifact, or a full exploration, produce ALL of them — do not reduce it to one, and do not cut it short to finish faster.',
|
|
2326
|
+
'- Keep the work bounded to the request: inspect only files needed; avoid broad repository scans unless they are necessary.',
|
|
2327
|
+
"- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the request.",
|
|
2328
|
+
// Token-frugality + the 10-min run cap: the worker used to run the WHOLE suite
|
|
2329
|
+
// (npm test) repeatedly, which the Manager ALSO runs at close-out — double work
|
|
2330
|
+
// that regularly overran the cap (→ a SIGTERM 'interrupted'). Write the test,
|
|
2331
|
+
// run only the RELEVANT file once, and leave the full-suite + verification to
|
|
2332
|
+
// the Manager. (Masa: worker が自前でテスト全回し→止まる、が最大の無駄。)
|
|
2333
|
+
'- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
|
|
2334
|
+
// Anti-wasteful-re-verify only — NOT a licence to do less. Once it works,
|
|
2335
|
+
// stop re-checking; but the deliverables above must still be produced in full.
|
|
2336
|
+
'- 動くと確認できたら十分(無駄な再検証でログを漁り続けない)。ただし依頼された成果物は削らず出し切ること。',
|
|
2337
|
+
'- Do not commit (git is handled by the Manager).',
|
|
2338
|
+
// How the human actually looks at the result. We never guess the run
|
|
2339
|
+
// command ourselves — guessing means branching on project type (Next.js vs
|
|
2340
|
+
// Python vs …), and that is the "smart PM" behaviour this layer must not
|
|
2341
|
+
// have. The only AI in the pipeline is the one that just did the work, so
|
|
2342
|
+
// it is the one that says how to run it. Requests with nothing to run
|
|
2343
|
+
// (research, summaries, answers) simply skip this — the worker decides,
|
|
2344
|
+
// never us.
|
|
2345
|
+
`- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
|
|
2346
|
+
// Links are rendered as links. The product cannot tell a real one from a
|
|
2347
|
+
// decorative one, so the only place this can be held is here.
|
|
2348
|
+
`- 自分が作っていない場所へのリンクを報告に書かない(存在しないURLや「詳しくはここ」のような飾りのリンクは、そのままリンクとして人に表示される)。`,
|
|
2349
|
+
// The camera. We say it exists and where the pictures go; we never say when
|
|
2350
|
+
// to use it — that is the worker's call, like the run declaration above.
|
|
2351
|
+
// Telling it "screenshot UI changes" would be this layer deciding what kind
|
|
2352
|
+
// of request it is, which is the behaviour that produced off-target proof.
|
|
2353
|
+
// ...but only to an agent that can actually take one. Codex runs its shell
|
|
2354
|
+
// inside a sandbox that Chrome cannot start under (measured 2026-07-22 on
|
|
2355
|
+
// codex-cli 0.142.4: the camera fails under `workspace-write` and works only
|
|
2356
|
+
// with the sandbox off — opening network access does not help). Offering it
|
|
2357
|
+
// anyway is not a harmless extra line: the Codex worker tried four times,
|
|
2358
|
+
// then reported the failure instead of the work. Do not ask an agent for
|
|
2359
|
+
// something this machine has proven it cannot do (Masa 2026-07-22).
|
|
2360
|
+
agentName === 'codex' ? '' : `- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>]\`(headless Chrome・前面に出ない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
|
|
2361
|
+
`- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
|
|
2362
|
+
].filter(Boolean).join('\n');
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// ---- RUN DECLARATION -------------------------------------------------------
|
|
2366
|
+
// The worker tells us how a human can run and look at what it built, by writing
|
|
2367
|
+
// RUN_DECL_FILE. This is the whole of P1's "動かして見られる状態": the evidence
|
|
2368
|
+
// is the real thing running, not a screenshot we staged.
|
|
2369
|
+
//
|
|
2370
|
+
// Why a file and not a line in the worker's report: writing a file is a tool
|
|
2371
|
+
// call the agent makes deliberately, where a report format is a wording habit
|
|
2372
|
+
// that drifts. And why the worker at all — the layer below (this one) must not
|
|
2373
|
+
// classify the request or the project to decide how to start it.
|
|
2374
|
+
//
|
|
2375
|
+
// Anything we cannot validate is dropped: no "run it" button is honest, a
|
|
2376
|
+
// button that opens the wrong thing is the fabricated-proof bug all over again.
|
|
2377
|
+
export const RUN_DECL_FILE = '.manager-run.json';
|
|
2378
|
+
|
|
2379
|
+
export function parseRunDeclaration(text) {
|
|
2380
|
+
let raw;
|
|
2381
|
+
try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
|
|
2382
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
2383
|
+
const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
|
|
2384
|
+
const cmd = str(raw.cmd, 500);
|
|
2385
|
+
const url = str(raw.url, 500);
|
|
2386
|
+
// Nothing to start and nothing to open = nothing to look at.
|
|
2387
|
+
if (!cmd && !url) return null;
|
|
2388
|
+
// Only http(s): the button opens this in the user's browser.
|
|
2389
|
+
if (url && !/^https?:\/\//i.test(url)) return null;
|
|
2390
|
+
// `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
|
|
2391
|
+
// to come from the intake AI's plan — this layer inventing a pass condition for a
|
|
2392
|
+
// request it only read. The AI that did the work states it here instead, in the same
|
|
2393
|
+
// file it already writes, and the review card shows that sentence verbatim.
|
|
2394
|
+
return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300) };
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// ---- SHOTS: the worker's own camera ----------------------------------------
|
|
2398
|
+
// The worker could build a screen and had no way to show it: WORKER_TOOLS has
|
|
2399
|
+
// no browser, so "見せて" could only ever come back as prose. That is one half
|
|
2400
|
+
// of the off-target-evidence problem (#496) — the other half was us staging the
|
|
2401
|
+
// picture ourselves, which is exactly what must not come back.
|
|
2402
|
+
//
|
|
2403
|
+
// So: hand the worker a camera and nothing else. `engine/shot.mjs` takes a URL
|
|
2404
|
+
// or a local file and writes a PNG in headless Chrome. This layer never decides
|
|
2405
|
+
// WHEN to shoot or WHAT to shoot — the worker that just did the work does, the
|
|
2406
|
+
// same way it already decides the run declaration above. No request-type
|
|
2407
|
+
// branching lives here.
|
|
2408
|
+
//
|
|
2409
|
+
// Shots land in SHOT_DIR inside the worktree and the Manager moves them out
|
|
2410
|
+
// before the diff is computed: they are a message to the Manager, not part of
|
|
2411
|
+
// the deliverable, so they must never reach the PR (same rule as RUN_DECL_FILE).
|
|
2412
|
+
export const SHOT_DIR = '.manager-shots';
|
|
2413
|
+
|
|
2414
|
+
// Absolute path to the camera, resolved from this file so it is correct in a
|
|
2415
|
+
// repo checkout and in an installed package alike (the worker runs in the
|
|
2416
|
+
// customer's worktree, which knows nothing about where the Manager lives).
|
|
2417
|
+
export const SHOT_CLI = (() => {
|
|
2418
|
+
const p = decodeURIComponent(new URL('shot.mjs', import.meta.url).pathname);
|
|
2419
|
+
return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
|
|
2420
|
+
})();
|
|
2421
|
+
|
|
2422
|
+
// Where Chrome might be. Env first so a customer with a non-standard install
|
|
2423
|
+
// (or a Linux box) can point at it; the rest are the default locations per
|
|
2424
|
+
// platform. Pure — the caller supplies `exists`, so this is testable.
|
|
2425
|
+
export function chromeCandidates(env = {}) {
|
|
2426
|
+
return [
|
|
2427
|
+
env.CHROME_PATH,
|
|
2428
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
2429
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
2430
|
+
'/usr/bin/google-chrome',
|
|
2431
|
+
'/usr/bin/chromium-browser',
|
|
2432
|
+
'/usr/bin/chromium',
|
|
2433
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
2434
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
2435
|
+
].filter(Boolean);
|
|
2436
|
+
}
|
|
2437
|
+
export function pickChromePath(env = {}, exists = () => false) {
|
|
2438
|
+
return chromeCandidates(env).find((c) => exists(c)) ?? null;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// `node <manager>/engine/shot.mjs <url|file> <out.png> [--width N] [--height N]
|
|
2442
|
+
// [--full] [--wait MS] [--wait-for SELECTOR]`
|
|
2443
|
+
// Returns { error } instead of throwing, so the CLI can print one clear line.
|
|
2444
|
+
export function parseShotArgs(argv = []) {
|
|
2445
|
+
const rest = [];
|
|
2446
|
+
const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null };
|
|
2447
|
+
const num = (v, lo, hi) => {
|
|
2448
|
+
const n = Number(v);
|
|
2449
|
+
return Number.isFinite(n) && n >= lo && n <= hi ? Math.round(n) : null;
|
|
2450
|
+
};
|
|
2451
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2452
|
+
const a = String(argv[i]);
|
|
2453
|
+
if (a === '--full') { opt.full = true; continue; }
|
|
2454
|
+
if (a === '--width' || a === '--height') {
|
|
2455
|
+
const n = num(argv[++i], 100, 4000);
|
|
2456
|
+
if (n == null) return { error: `${a} needs a number between 100 and 4000` };
|
|
2457
|
+
opt[a.slice(2)] = n; continue;
|
|
2458
|
+
}
|
|
2459
|
+
if (a === '--wait') {
|
|
2460
|
+
const n = num(argv[++i], 0, 30000);
|
|
2461
|
+
if (n == null) return { error: '--wait needs milliseconds between 0 and 30000' };
|
|
2462
|
+
opt.wait = n; continue;
|
|
2463
|
+
}
|
|
2464
|
+
if (a === '--wait-for') {
|
|
2465
|
+
const sel = String(argv[++i] ?? '').trim();
|
|
2466
|
+
if (!sel) return { error: '--wait-for needs a CSS selector' };
|
|
2467
|
+
opt.waitFor = sel; continue;
|
|
2468
|
+
}
|
|
2469
|
+
if (a.startsWith('--')) return { error: `unknown option ${a}` };
|
|
2470
|
+
rest.push(a);
|
|
2471
|
+
}
|
|
2472
|
+
const [target, out] = rest;
|
|
2473
|
+
if (!target || !out) return { error: 'usage: node engine/shot.mjs <url|file> <out.png> [--width N] [--height N] [--full] [--wait MS] [--wait-for SELECTOR]' };
|
|
2474
|
+
if (!/\.png$/i.test(out)) return { error: 'output must end in .png' };
|
|
2475
|
+
return { target, out, ...opt };
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// http(s) passes through; anything else is a local path → file:// URL.
|
|
2479
|
+
// `resolvePath` is injected so this stays pure and testable.
|
|
2480
|
+
export function shotTargetUrl(target, resolvePath = (p) => p) {
|
|
2481
|
+
const t = String(target ?? '').trim();
|
|
2482
|
+
if (!t) return null;
|
|
2483
|
+
if (/^(https?|file):\/\//i.test(t)) return t;
|
|
2484
|
+
const abs = String(resolvePath(t)).replace(/\\/g, '/');
|
|
2485
|
+
const withSlash = abs.startsWith('/') ? abs : `/${abs}`;
|
|
2486
|
+
return `file://${withSlash.split('/').map((seg, i) => (i === 0 ? seg : encodeURIComponent(seg))).join('/')}`;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// What the Manager takes out of SHOT_DIR: images only, no subpaths or dotfiles
|
|
2490
|
+
// (they become filenames in the Manager's own store), capped so a runaway loop
|
|
2491
|
+
// cannot bloat the record.
|
|
2492
|
+
export const SHOT_MAX = 20;
|
|
2493
|
+
export function collectShotNames(names = []) {
|
|
2494
|
+
return names
|
|
2495
|
+
.filter((n) => typeof n === 'string' && /\.(png|jpe?g|gif|webp)$/i.test(n))
|
|
2496
|
+
.filter((n) => !n.includes('/') && !n.includes('\\') && !n.startsWith('.'))
|
|
2497
|
+
.sort()
|
|
2498
|
+
.slice(0, SHOT_MAX);
|
|
2499
|
+
}
|
|
2087
2500
|
|
|
2088
2501
|
// ---- SKILL picker ----------------------------------------------------------
|
|
2089
2502
|
// Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`
|