@galda/cli 0.10.37 → 0.10.39
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 +742 -401
- package/engine/lib.mjs +344 -37
- package/engine/server.mjs +248 -248
- package/engine/shot.mjs +96 -0
- package/package.json +1 -1
package/engine/lib.mjs
CHANGED
|
@@ -590,6 +590,40 @@ export function isEngineeringPhaseTitle(title) {
|
|
|
590
590
|
return ENGINEERING_PHASE_RE.test(String(title ?? ''));
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
+
// ---- title = the user's REQUEST, not a completion report -------------------
|
|
594
|
+
// Masa (goal #474): the planner echoed a worker's finished-work sentence as the
|
|
595
|
+
// title —「アニメーションが終わってから歯車の位置を計算するように直した」— instead of
|
|
596
|
+
// naming what the user asked for. A title should read like the ask ("…のバグ修正"),
|
|
597
|
+
// never like a past-tense report of HOW it was implemented. Detect that shape so
|
|
598
|
+
// we can fall back to the user's own words. NARROW on purpose: only past-tense
|
|
599
|
+
// completion phrasing (…した/…ました, "…ように直/修/変") — a genuine request title
|
|
600
|
+
// never ends that way.
|
|
601
|
+
const IMPL_REPORT_RE = /(直し|修正し|変更し|追加し|対応し|実装し|計算する|するように|完了し)(た|ました)?/;
|
|
602
|
+
const PAST_DONE_RE = /(直した|修正した|変更した|追加した|対応した|実装した|しました|ました|するように直|するように修正|するように変更)/;
|
|
603
|
+
export function isImplementationReportTitle(title) {
|
|
604
|
+
const s = String(title ?? '').trim();
|
|
605
|
+
if (!s) return false;
|
|
606
|
+
// Report shape = past-tense completion OR "…するように<動詞>" (describes the how).
|
|
607
|
+
return PAST_DONE_RE.test(s) || (/するように/.test(s) && IMPL_REPORT_RE.test(s));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Derive a short, user-worded title from the raw request text. Strips the polite
|
|
611
|
+
// request tail (…して/してください/直して/お願いします 等) so the title reads as the
|
|
612
|
+
// thing itself (「…バグ修正して」→「…バグ修正」) — the user's words, not a rephrase.
|
|
613
|
+
export function titleFromRequest(goalText, max = 60) {
|
|
614
|
+
let s = String(goalText ?? '').replace(/\s+/g, ' ').trim();
|
|
615
|
+
if (!s) return '';
|
|
616
|
+
// Drop a trailing polite/imperative request tail so the noun-phrase remains.
|
|
617
|
+
// Order matters: strip the courtesy suffix first, then the verb 直す ("…を直して"
|
|
618
|
+
// is a verb — drop the whole thing), then the する light verb ("…修正して" keeps
|
|
619
|
+
// the noun 修正, drops only して).
|
|
620
|
+
s = s.replace(/[\s、。,.,.!!??]*(?:お願いします|おねがいします|お願い|please)[。.!!]*$/iu, '')
|
|
621
|
+
.replace(/[\s、。,.,.!!??]*(?:を)?直して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
|
|
622
|
+
.replace(/[\s、。,.,.!!??]*して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
|
|
623
|
+
.trim();
|
|
624
|
+
return s.slice(0, max);
|
|
625
|
+
}
|
|
626
|
+
|
|
593
627
|
// Reduce a planner's task list to USER-TOPIC granularity — the deterministic
|
|
594
628
|
// backstop for when the LLM over-decomposes despite the prompt. Two moves, both
|
|
595
629
|
// conservative so a distinct ask is never silently dropped:
|
|
@@ -603,7 +637,13 @@ export function isEngineeringPhaseTitle(title) {
|
|
|
603
637
|
// worker still owns the full engineering breakdown internally.
|
|
604
638
|
export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
|
|
605
639
|
const list = (tasks ?? []).filter((t) => t && String(t.title ?? '').trim());
|
|
606
|
-
|
|
640
|
+
// A title that reads as a completion report ("…するように直した") is replaced with
|
|
641
|
+
// the user's own request wording. Only safe when there's a single topic — with
|
|
642
|
+
// multiple distinct deliverables the goal text can't stand in for any one title.
|
|
643
|
+
const userTitled = (arr) => arr.length === 1 && isImplementationReportTitle(arr[0].title) && titleFromRequest(goalText)
|
|
644
|
+
? [{ ...arr[0], title: titleFromRequest(goalText) }]
|
|
645
|
+
: arr;
|
|
646
|
+
if (list.length <= 1) return userTitled(list.slice(0, maxTasks));
|
|
607
647
|
const real = list.filter((t) => !isEngineeringPhaseTitle(t.title));
|
|
608
648
|
if (!real.length) {
|
|
609
649
|
const title = String(goalText ?? list[0].title).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
@@ -611,7 +651,7 @@ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
|
|
|
611
651
|
const passCondition = list.find((t) => t.passCondition)?.passCondition ?? null;
|
|
612
652
|
return [{ title, detail, passCondition }];
|
|
613
653
|
}
|
|
614
|
-
return real.slice(0, maxTasks);
|
|
654
|
+
return userTitled(real.slice(0, maxTasks));
|
|
615
655
|
}
|
|
616
656
|
|
|
617
657
|
// ---- dedupe / merge overlapping goals --------------------------------------
|
|
@@ -870,8 +910,30 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
|
|
|
870
910
|
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
911
|
`Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItemsCount ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
|
|
872
912
|
'',
|
|
913
|
+
// How the reviewer sees the thing for themselves, in the worker's own
|
|
914
|
+
// words. Above the reports because "run it and look" beats reading about
|
|
915
|
+
// it — and because for a request with nothing to run this section is simply
|
|
916
|
+
// absent rather than filled with a staged screenshot.
|
|
917
|
+
...(() => {
|
|
918
|
+
const runs = goalTasks.filter((t) => t.run && (t.run.cmd || t.run.url));
|
|
919
|
+
if (!runs.length) return [];
|
|
920
|
+
return [
|
|
921
|
+
'',
|
|
922
|
+
L('## See it running', '## 動かして見る'),
|
|
923
|
+
'',
|
|
924
|
+
...runs.flatMap((t) => [
|
|
925
|
+
t.run.note ? `${t.run.note}` : '',
|
|
926
|
+
t.run.cmd ? '```\n' + t.run.cmd + '\n```' : '',
|
|
927
|
+
t.run.url ? `→ ${t.run.url}` : '',
|
|
928
|
+
].filter(Boolean)),
|
|
929
|
+
];
|
|
930
|
+
})(),
|
|
931
|
+
'',
|
|
873
932
|
L('## Tasks and worker reports', '## タスクとworker報告'),
|
|
874
|
-
|
|
933
|
+
// 800 chars used to cut the worker's report mid-sentence. For a request
|
|
934
|
+
// that produces no runnable thing (research, a summary, an answer) that
|
|
935
|
+
// report IS the deliverable, so truncating it threw away the result.
|
|
936
|
+
...goalTasks.map((t) => `\n### ${t.num} ${t.title} — ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 8000)}`),
|
|
875
937
|
'',
|
|
876
938
|
L(`Changed files: ${files.map((f) => `\`${f}\``).join(' ')}`, `変更ファイル: ${files.map((f) => `\`${f}\``).join(' ')}`),
|
|
877
939
|
].join('\n');
|
|
@@ -913,28 +975,6 @@ export function parseReviewSummary(raw, fallback = {}) {
|
|
|
913
975
|
};
|
|
914
976
|
}
|
|
915
977
|
|
|
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
978
|
const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
|
|
939
979
|
export function hasTestRelevantChanges(changedFiles) {
|
|
940
980
|
const files = (changedFiles ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
|
|
@@ -1009,17 +1049,25 @@ export function classifyVerifyGate({
|
|
|
1009
1049
|
return { blocked, kind, proofAdvisory };
|
|
1010
1050
|
}
|
|
1011
1051
|
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1052
|
+
// Serializes async work so at most one item runs at a time. The goal-verify
|
|
1053
|
+
// pipeline uses this to stop two goals' `npm test` runs (each spins up real
|
|
1054
|
+
// headless Chrome + real servers) from ever overlapping — running two full
|
|
1055
|
+
// suites at once was the actual cause of goal #552/#487 each blocking on a
|
|
1056
|
+
// DIFFERENT flaky test on every retry (2026-07-21 postmortem): the requested
|
|
1057
|
+
// feature was already correct, but the shared machine's CPU/ports/tmp-dirs
|
|
1058
|
+
// couldn't support two concurrent suites, so the baseline-diff gate never
|
|
1059
|
+
// saw the same failure twice in a row and never converged to "no new
|
|
1060
|
+
// failures". Queued work still runs even if an earlier item throws/rejects —
|
|
1061
|
+
// one bad run must not wedge every goal behind it forever.
|
|
1062
|
+
export function createSerialQueue() {
|
|
1063
|
+
let tail = Promise.resolve();
|
|
1064
|
+
return {
|
|
1065
|
+
run(fn) {
|
|
1066
|
+
const started = tail.then(fn, fn);
|
|
1067
|
+
tail = started.then(() => undefined, () => undefined);
|
|
1068
|
+
return started;
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1023
1071
|
}
|
|
1024
1072
|
|
|
1025
1073
|
// Task 56: a project's review definition can carry a free-text `description`
|
|
@@ -1179,6 +1227,20 @@ export function goalGroupStatus(goalStatus, startedAt) {
|
|
|
1179
1227
|
return 'done'; // includes 'reverted' (terminal)
|
|
1180
1228
|
}
|
|
1181
1229
|
|
|
1230
|
+
// PRD §6.5 → Masa 2026-07-21: a folded goal no longer takes its own row in the Review
|
|
1231
|
+
// column. The standalone "→ #12 にまとめました" rows read as clutter ("まとめましたは
|
|
1232
|
+
// review欄にいらない"); the merge belongs inside the carrier review's detail, as a log of
|
|
1233
|
+
// what was folded into it. This selects the folded goals for a given carrier review (a
|
|
1234
|
+
// review can group several goalIds under one PR, so match against the whole set). Pure
|
|
1235
|
+
// so the browser mirror in app/index.html (non-module, can't import this) and the test
|
|
1236
|
+
// agree on exactly which goals a carrier review logs.
|
|
1237
|
+
export function foldedIntoReview(goals, targetGoalIds) {
|
|
1238
|
+
const ids = new Set((targetGoalIds ?? []).map(Number));
|
|
1239
|
+
return (goals ?? []).filter(
|
|
1240
|
+
(g) => g && g.status === 'folded' && g.foldedInto != null && ids.has(Number(g.foldedInto)),
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1182
1244
|
// Label + CSS class for the small status chip shown next to a goal in the
|
|
1183
1245
|
// goal list and goal detail thread — mirrored in app/index.html's GOAL_CHIP
|
|
1184
1246
|
// since that inline script can't import this module.
|
|
@@ -2083,7 +2145,252 @@ const WORKER_BASH = [
|
|
|
2083
2145
|
'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
|
|
2084
2146
|
'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
|
|
2085
2147
|
].map((c) => `Bash(${c}:*)`).join(',');
|
|
2086
|
-
|
|
2148
|
+
// WebFetch/WebSearch: a plain Claude Code session has both, and a worker that
|
|
2149
|
+
// cannot open a page or look anything up fails a whole class of requests that
|
|
2150
|
+
// work locally ("この記事のとおりに直して", "現行の仕様を確認して"). They are
|
|
2151
|
+
// read-only, so they widen what the worker can SEE, not what it can change.
|
|
2152
|
+
export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,WebFetch,WebSearch,${WORKER_BASH}`;
|
|
2153
|
+
|
|
2154
|
+
// ---- goal → single task (demoted planner) ----------------------------------
|
|
2155
|
+
// P0b (Masa 2026-07-21): the planner no longer decomposes the request into
|
|
2156
|
+
// tasks. The request reaches the worker verbatim as ONE task; the worker
|
|
2157
|
+
// (Claude Code / Codex) decides its own breakdown and emits it as its own
|
|
2158
|
+
// TodoWrite, which the board surfaces. One card = one request.
|
|
2159
|
+
|
|
2160
|
+
// A short, single-line display label for the task, derived from the request
|
|
2161
|
+
// itself (no LLM rewrite). First non-empty line, trimmed and truncated.
|
|
2162
|
+
export function goalTaskTitle(text, max = 80) {
|
|
2163
|
+
const firstLine = String(text ?? '').split('\n').map((s) => s.trim()).find(Boolean) ?? '';
|
|
2164
|
+
return firstLine.length > max ? firstLine.slice(0, max - 1).trimEnd() + '…' : firstLine;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// Build the single task that carries a goal's request verbatim to the worker.
|
|
2168
|
+
// `id`, `num`, `createdAt` are supplied by the caller (stateful/impure bits);
|
|
2169
|
+
// everything else is derived from the goal so this stays pure & testable.
|
|
2170
|
+
// `passCondition` rides along only when the user wrote an explicit one.
|
|
2171
|
+
export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }) {
|
|
2172
|
+
return {
|
|
2173
|
+
id, num, goalId: goal.id, projectId: goal.projectId,
|
|
2174
|
+
title: goalTaskTitle(goal.text), detail: goal.text, passCondition,
|
|
2175
|
+
model: goal.model, effort: goal.effort, agent: goal.agent,
|
|
2176
|
+
mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
|
|
2177
|
+
status: 'queued', createdAt, priority: '中',
|
|
2178
|
+
result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// ---- worker prompt ---------------------------------------------------------
|
|
2183
|
+
// The instruction the worker (Claude Code / Codex) actually reads. Pure:
|
|
2184
|
+
// (task, goal, lastFailure, agentName) -> string, so it is unit-testable.
|
|
2185
|
+
// `agentName` is the resolved worker agent id ('codex' | 'claude-code'); the
|
|
2186
|
+
// caller resolves it (server keeps the WORKER_AGENTS table).
|
|
2187
|
+
//
|
|
2188
|
+
// P0a "un-lobotomy" (Masa 2026-07-21, goal #496): the harness used to (a) trim
|
|
2189
|
+
// the user's real request to 500 chars and bury it as "part of the overall
|
|
2190
|
+
// goal", and (b) inject suppressive RULES ("keep changes minimal", "make the
|
|
2191
|
+
// smallest safe change instead of expanding scope", "検証しすぎない…すぐ終える").
|
|
2192
|
+
// Together they turned "design 3 patterns as an artifact" into one minimal
|
|
2193
|
+
// tweak. North star: we never re-judge the request — the user's verbatim words
|
|
2194
|
+
// are the source of truth and the worker delivers exactly what they ask for.
|
|
2195
|
+
export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code') {
|
|
2196
|
+
// Optional skill the user attached to this goal. Empirically (scratch worker,
|
|
2197
|
+
// haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
|
|
2198
|
+
// instruction as the FIRST line; a bare "/name" first line did not consistently
|
|
2199
|
+
// trigger the skill in `claude -p`. See commit body for the run evidence.
|
|
2200
|
+
const skill = task?.skill ?? goal?.skill;
|
|
2201
|
+
const displayName = agentName === 'codex' ? 'Codex' : 'Claude Code';
|
|
2202
|
+
// Report back to the human in the SAME language they wrote the goal in (Masa
|
|
2203
|
+
// 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
|
|
2204
|
+
const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
|
|
2205
|
+
return [
|
|
2206
|
+
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.` : '',
|
|
2207
|
+
`You are a ${displayName} worker managed by "Manager for AI". Do the request below inside this project directory.`,
|
|
2208
|
+
goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
|
|
2209
|
+
'',
|
|
2210
|
+
// The user's own words, verbatim and in full, are the source of truth.
|
|
2211
|
+
// TASK/DETAIL below is a paraphrase from the planner — when they differ,
|
|
2212
|
+
// follow the user's words.
|
|
2213
|
+
goal?.text ? `USER'S REQUEST (verbatim — the source of truth; deliver exactly this):\n${goal.text}` : '',
|
|
2214
|
+
'',
|
|
2215
|
+
`TASK ${task.num}: ${task.title}`,
|
|
2216
|
+
task.detail ? `DETAIL: ${task.detail}` : '',
|
|
2217
|
+
goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
|
|
2218
|
+
task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
|
|
2219
|
+
lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
|
|
2220
|
+
'',
|
|
2221
|
+
'RULES:',
|
|
2222
|
+
'- Edit only files inside this directory and this project. Stay on this request; do not wander into unrelated work.',
|
|
2223
|
+
// Deliverable fidelity (replaces the old "make the smallest safe change
|
|
2224
|
+
// instead of expanding scope"): the request, not our fear of scope, sets
|
|
2225
|
+
// the size of the work.
|
|
2226
|
+
'- 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.',
|
|
2227
|
+
'- Keep the work bounded to the request: inspect only files needed; avoid broad repository scans unless they are necessary.',
|
|
2228
|
+
"- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the request.",
|
|
2229
|
+
// Token-frugality + the 10-min run cap: the worker used to run the WHOLE suite
|
|
2230
|
+
// (npm test) repeatedly, which the Manager ALSO runs at close-out — double work
|
|
2231
|
+
// that regularly overran the cap (→ a SIGTERM 'interrupted'). Write the test,
|
|
2232
|
+
// run only the RELEVANT file once, and leave the full-suite + verification to
|
|
2233
|
+
// the Manager. (Masa: worker が自前でテスト全回し→止まる、が最大の無駄。)
|
|
2234
|
+
'- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
|
|
2235
|
+
// Anti-wasteful-re-verify only — NOT a licence to do less. Once it works,
|
|
2236
|
+
// stop re-checking; but the deliverables above must still be produced in full.
|
|
2237
|
+
'- 動くと確認できたら十分(無駄な再検証でログを漁り続けない)。ただし依頼された成果物は削らず出し切ること。',
|
|
2238
|
+
'- Do not commit (git is handled by the Manager).',
|
|
2239
|
+
// How the human actually looks at the result. We never guess the run
|
|
2240
|
+
// command ourselves — guessing means branching on project type (Next.js vs
|
|
2241
|
+
// Python vs …), and that is the "smart PM" behaviour this layer must not
|
|
2242
|
+
// have. The only AI in the pipeline is the one that just did the work, so
|
|
2243
|
+
// it is the one that says how to run it. Requests with nothing to run
|
|
2244
|
+
// (research, summaries, answers) simply skip this — the worker decides,
|
|
2245
|
+
// never us.
|
|
2246
|
+
`- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
|
|
2247
|
+
// Links are rendered as links. The product cannot tell a real one from a
|
|
2248
|
+
// decorative one, so the only place this can be held is here.
|
|
2249
|
+
`- 自分が作っていない場所へのリンクを報告に書かない(存在しないURLや「詳しくはここ」のような飾りのリンクは、そのままリンクとして人に表示される)。`,
|
|
2250
|
+
// The camera. We say it exists and where the pictures go; we never say when
|
|
2251
|
+
// to use it — that is the worker's call, like the run declaration above.
|
|
2252
|
+
// Telling it "screenshot UI changes" would be this layer deciding what kind
|
|
2253
|
+
// of request it is, which is the behaviour that produced off-target proof.
|
|
2254
|
+
`- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>]\`(headless Chrome・前面に出ない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
|
|
2255
|
+
`- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
|
|
2256
|
+
].filter(Boolean).join('\n');
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// ---- RUN DECLARATION -------------------------------------------------------
|
|
2260
|
+
// The worker tells us how a human can run and look at what it built, by writing
|
|
2261
|
+
// RUN_DECL_FILE. This is the whole of P1's "動かして見られる状態": the evidence
|
|
2262
|
+
// is the real thing running, not a screenshot we staged.
|
|
2263
|
+
//
|
|
2264
|
+
// Why a file and not a line in the worker's report: writing a file is a tool
|
|
2265
|
+
// call the agent makes deliberately, where a report format is a wording habit
|
|
2266
|
+
// that drifts. And why the worker at all — the layer below (this one) must not
|
|
2267
|
+
// classify the request or the project to decide how to start it.
|
|
2268
|
+
//
|
|
2269
|
+
// Anything we cannot validate is dropped: no "run it" button is honest, a
|
|
2270
|
+
// button that opens the wrong thing is the fabricated-proof bug all over again.
|
|
2271
|
+
export const RUN_DECL_FILE = '.manager-run.json';
|
|
2272
|
+
|
|
2273
|
+
export function parseRunDeclaration(text) {
|
|
2274
|
+
let raw;
|
|
2275
|
+
try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
|
|
2276
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
2277
|
+
const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
|
|
2278
|
+
const cmd = str(raw.cmd, 500);
|
|
2279
|
+
const url = str(raw.url, 500);
|
|
2280
|
+
// Nothing to start and nothing to open = nothing to look at.
|
|
2281
|
+
if (!cmd && !url) return null;
|
|
2282
|
+
// Only http(s): the button opens this in the user's browser.
|
|
2283
|
+
if (url && !/^https?:\/\//i.test(url)) return null;
|
|
2284
|
+
// `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
|
|
2285
|
+
// to come from the intake AI's plan — this layer inventing a pass condition for a
|
|
2286
|
+
// request it only read. The AI that did the work states it here instead, in the same
|
|
2287
|
+
// file it already writes, and the review card shows that sentence verbatim.
|
|
2288
|
+
return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300) };
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// ---- SHOTS: the worker's own camera ----------------------------------------
|
|
2292
|
+
// The worker could build a screen and had no way to show it: WORKER_TOOLS has
|
|
2293
|
+
// no browser, so "見せて" could only ever come back as prose. That is one half
|
|
2294
|
+
// of the off-target-evidence problem (#496) — the other half was us staging the
|
|
2295
|
+
// picture ourselves, which is exactly what must not come back.
|
|
2296
|
+
//
|
|
2297
|
+
// So: hand the worker a camera and nothing else. `engine/shot.mjs` takes a URL
|
|
2298
|
+
// or a local file and writes a PNG in headless Chrome. This layer never decides
|
|
2299
|
+
// WHEN to shoot or WHAT to shoot — the worker that just did the work does, the
|
|
2300
|
+
// same way it already decides the run declaration above. No request-type
|
|
2301
|
+
// branching lives here.
|
|
2302
|
+
//
|
|
2303
|
+
// Shots land in SHOT_DIR inside the worktree and the Manager moves them out
|
|
2304
|
+
// before the diff is computed: they are a message to the Manager, not part of
|
|
2305
|
+
// the deliverable, so they must never reach the PR (same rule as RUN_DECL_FILE).
|
|
2306
|
+
export const SHOT_DIR = '.manager-shots';
|
|
2307
|
+
|
|
2308
|
+
// Absolute path to the camera, resolved from this file so it is correct in a
|
|
2309
|
+
// repo checkout and in an installed package alike (the worker runs in the
|
|
2310
|
+
// customer's worktree, which knows nothing about where the Manager lives).
|
|
2311
|
+
export const SHOT_CLI = (() => {
|
|
2312
|
+
const p = decodeURIComponent(new URL('shot.mjs', import.meta.url).pathname);
|
|
2313
|
+
return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
|
|
2314
|
+
})();
|
|
2315
|
+
|
|
2316
|
+
// Where Chrome might be. Env first so a customer with a non-standard install
|
|
2317
|
+
// (or a Linux box) can point at it; the rest are the default locations per
|
|
2318
|
+
// platform. Pure — the caller supplies `exists`, so this is testable.
|
|
2319
|
+
export function chromeCandidates(env = {}) {
|
|
2320
|
+
return [
|
|
2321
|
+
env.CHROME_PATH,
|
|
2322
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
2323
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
2324
|
+
'/usr/bin/google-chrome',
|
|
2325
|
+
'/usr/bin/chromium-browser',
|
|
2326
|
+
'/usr/bin/chromium',
|
|
2327
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
2328
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
2329
|
+
].filter(Boolean);
|
|
2330
|
+
}
|
|
2331
|
+
export function pickChromePath(env = {}, exists = () => false) {
|
|
2332
|
+
return chromeCandidates(env).find((c) => exists(c)) ?? null;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// `node <manager>/engine/shot.mjs <url|file> <out.png> [--width N] [--height N]
|
|
2336
|
+
// [--full] [--wait MS] [--wait-for SELECTOR]`
|
|
2337
|
+
// Returns { error } instead of throwing, so the CLI can print one clear line.
|
|
2338
|
+
export function parseShotArgs(argv = []) {
|
|
2339
|
+
const rest = [];
|
|
2340
|
+
const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null };
|
|
2341
|
+
const num = (v, lo, hi) => {
|
|
2342
|
+
const n = Number(v);
|
|
2343
|
+
return Number.isFinite(n) && n >= lo && n <= hi ? Math.round(n) : null;
|
|
2344
|
+
};
|
|
2345
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2346
|
+
const a = String(argv[i]);
|
|
2347
|
+
if (a === '--full') { opt.full = true; continue; }
|
|
2348
|
+
if (a === '--width' || a === '--height') {
|
|
2349
|
+
const n = num(argv[++i], 100, 4000);
|
|
2350
|
+
if (n == null) return { error: `${a} needs a number between 100 and 4000` };
|
|
2351
|
+
opt[a.slice(2)] = n; continue;
|
|
2352
|
+
}
|
|
2353
|
+
if (a === '--wait') {
|
|
2354
|
+
const n = num(argv[++i], 0, 30000);
|
|
2355
|
+
if (n == null) return { error: '--wait needs milliseconds between 0 and 30000' };
|
|
2356
|
+
opt.wait = n; continue;
|
|
2357
|
+
}
|
|
2358
|
+
if (a === '--wait-for') {
|
|
2359
|
+
const sel = String(argv[++i] ?? '').trim();
|
|
2360
|
+
if (!sel) return { error: '--wait-for needs a CSS selector' };
|
|
2361
|
+
opt.waitFor = sel; continue;
|
|
2362
|
+
}
|
|
2363
|
+
if (a.startsWith('--')) return { error: `unknown option ${a}` };
|
|
2364
|
+
rest.push(a);
|
|
2365
|
+
}
|
|
2366
|
+
const [target, out] = rest;
|
|
2367
|
+
if (!target || !out) return { error: 'usage: node engine/shot.mjs <url|file> <out.png> [--width N] [--height N] [--full] [--wait MS] [--wait-for SELECTOR]' };
|
|
2368
|
+
if (!/\.png$/i.test(out)) return { error: 'output must end in .png' };
|
|
2369
|
+
return { target, out, ...opt };
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// http(s) passes through; anything else is a local path → file:// URL.
|
|
2373
|
+
// `resolvePath` is injected so this stays pure and testable.
|
|
2374
|
+
export function shotTargetUrl(target, resolvePath = (p) => p) {
|
|
2375
|
+
const t = String(target ?? '').trim();
|
|
2376
|
+
if (!t) return null;
|
|
2377
|
+
if (/^(https?|file):\/\//i.test(t)) return t;
|
|
2378
|
+
const abs = String(resolvePath(t)).replace(/\\/g, '/');
|
|
2379
|
+
const withSlash = abs.startsWith('/') ? abs : `/${abs}`;
|
|
2380
|
+
return `file://${withSlash.split('/').map((seg, i) => (i === 0 ? seg : encodeURIComponent(seg))).join('/')}`;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// What the Manager takes out of SHOT_DIR: images only, no subpaths or dotfiles
|
|
2384
|
+
// (they become filenames in the Manager's own store), capped so a runaway loop
|
|
2385
|
+
// cannot bloat the record.
|
|
2386
|
+
export const SHOT_MAX = 20;
|
|
2387
|
+
export function collectShotNames(names = []) {
|
|
2388
|
+
return names
|
|
2389
|
+
.filter((n) => typeof n === 'string' && /\.(png|jpe?g|gif|webp)$/i.test(n))
|
|
2390
|
+
.filter((n) => !n.includes('/') && !n.includes('\\') && !n.startsWith('.'))
|
|
2391
|
+
.sort()
|
|
2392
|
+
.slice(0, SHOT_MAX);
|
|
2393
|
+
}
|
|
2087
2394
|
|
|
2088
2395
|
// ---- SKILL picker ----------------------------------------------------------
|
|
2089
2396
|
// Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`
|