@galda/cli 0.10.40 → 0.10.41
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/app/index.html +650 -68
- package/engine/lib.mjs +269 -87
- package/engine/server.mjs +348 -70
- package/engine/shot.mjs +16 -1
- package/package.json +1 -1
package/engine/lib.mjs
CHANGED
|
@@ -227,7 +227,13 @@ export function buildExecutionPlan({ projectId = null, text = '', usage = null,
|
|
|
227
227
|
session.mode === 'compact' ? 'compact handoff' : null,
|
|
228
228
|
session.mode === 'cold-handoff' ? 'fresh handoff' : null,
|
|
229
229
|
session.mode === 'cold' ? '/clear equivalent' : null,
|
|
230
|
-
|
|
230
|
+
// 'worker policy' used to be listed here and it never was one: the effort
|
|
231
|
+
// chooseWorkerPolicy picks is not applied to the worker (it only reaches the
|
|
232
|
+
// handoff prompt as a sentence), and Masa ruled on 2026-07-22 that it must
|
|
233
|
+
// NOT start being applied — guessing effort from keywords in the request is
|
|
234
|
+
// the request-classification this layer is not allowed to do. A technique
|
|
235
|
+
// list that names something we did not do is what makes the saved figure
|
|
236
|
+
// untrustworthy, so it is gone rather than kept as a label.
|
|
231
237
|
].filter(Boolean);
|
|
232
238
|
return {
|
|
233
239
|
projectGroupId: projectId,
|
|
@@ -301,15 +307,20 @@ export function estimateTokenOptimization({ usage = null, weightedTokens = null,
|
|
|
301
307
|
const perReq = weighted / reqN;
|
|
302
308
|
const baseline = tokenBaselinePerRequirement(history);
|
|
303
309
|
|
|
304
|
-
|
|
310
|
+
// A percentage is only claimed against this user's own recent runs. The old
|
|
311
|
+
// fallback — with no history, score the run against its own cache-weighting —
|
|
312
|
+
// produced the number the PRD itself called out as a perverse incentive: the
|
|
313
|
+
// MORE context a run dragged along, the bigger its cache read, and the better
|
|
314
|
+
// the "saving" looked. That made the first runs of every new install, the
|
|
315
|
+
// ones with no history, the ones that bragged most (Masa 2026-07-22). Now
|
|
316
|
+
// there is simply no percentage until there is something real to compare to;
|
|
317
|
+
// the tokens and the techniques below still ship, so the card shows facts
|
|
318
|
+
// instead of a flattering ratio.
|
|
319
|
+
let percent = null, basis = 'no-baseline', savedTokens = null;
|
|
305
320
|
if (baseline != null) {
|
|
306
321
|
percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
|
|
307
322
|
basis = 'baseline';
|
|
308
323
|
savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
|
|
309
|
-
} else {
|
|
310
|
-
savedTokens = Math.max(0, raw - weighted);
|
|
311
|
-
percent = Math.round((savedTokens / raw) * 100);
|
|
312
|
-
basis = 'cache-weight';
|
|
313
324
|
}
|
|
314
325
|
|
|
315
326
|
// "why": always at least one concrete, data-derived sentence — never a
|
|
@@ -320,7 +331,10 @@ export function estimateTokenOptimization({ usage = null, weightedTokens = null,
|
|
|
320
331
|
if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
|
|
321
332
|
if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
|
|
322
333
|
if (usedClear) techniques.push('/clear between unrelated goals');
|
|
323
|
-
|
|
334
|
+
// The model is NOT listed any more. Naming it here read as something this
|
|
335
|
+
// layer did to save tokens, and it is the opposite: the model is the user's
|
|
336
|
+
// own pick from the composer, and we deliberately never override it
|
|
337
|
+
// (Masa 2026-07-22). Every remaining line is an action this layer took.
|
|
324
338
|
if (basis === 'baseline' && percent > 0) {
|
|
325
339
|
techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
326
340
|
}
|
|
@@ -744,65 +758,51 @@ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
|
|
|
744
758
|
return userTitled(real.slice(0, maxTasks));
|
|
745
759
|
}
|
|
746
760
|
|
|
747
|
-
// ---- dedupe / merge overlapping goals
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
761
|
+
// ---- dedupe / merge overlapping goals — REMOVED 2026-07-22 ----------------
|
|
762
|
+
// normalizeGoalText / goalTextSimilarity / findOverlappingGoal lived here: a
|
|
763
|
+
// word-overlap score that folded a new request into an in-flight goal whenever
|
|
764
|
+
// the two texts scored >= 0.72 alike. Deleted with its call site
|
|
765
|
+
// (docs/design/ONE-CONVERSATION.md §1.2) — it was our layer deciding "this is
|
|
766
|
+
// probably the same thing" on the user's behalf. See parseGoalAddress below:
|
|
767
|
+
// the person names the ticket, or the request becomes its own.
|
|
768
|
+
|
|
769
|
+
// ---- the conversation on a goal (docs/design/ONE-CONVERSATION.md §1.1) -----
|
|
770
|
+
// 「1本の会話・複数の入口」の入れ物。今日までの連続性は Claude Code 側の
|
|
771
|
+
// sessionId だけに預けてあり、上書きされたり resume に失敗すると我々の側には
|
|
772
|
+
// 何も残らなかった。ゴール自身がやりとりを持てば、セッションが飛んでも人には
|
|
773
|
+
// 見える。
|
|
751
774
|
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
775
|
+
// 原則(Masa確定 2026-07-22):
|
|
776
|
+
// - 要約しない・分類しない・間引かない。言われた言葉をそのまま全件。
|
|
777
|
+
// - 1発言の長さにだけ上限(極端な貼り付けで記録が壊れないように)。
|
|
778
|
+
// - /api/state には載せない。開いた時だけ取りに行く(実装ログ #234/#238 と同じ形)。
|
|
779
|
+
export const MESSAGE_TEXT_LIMIT = 8000;
|
|
780
|
+
export const MESSAGE_FROM = ['you', 'ai', 'manager'];
|
|
781
|
+
export const MESSAGE_VIA = ['composer', 'review', 'thread'];
|
|
782
|
+
|
|
783
|
+
// 1発言を正規化する。text が空なら null(空の発言は記録しない=無いものを
|
|
784
|
+
// 残さない)。from/via が未知なら記録側の都合で捨てず、既定に寄せる。
|
|
785
|
+
export function buildGoalMessage({ from, via, text, at } = {}) {
|
|
786
|
+
const body = String(text ?? '').trim();
|
|
787
|
+
if (!body) return null;
|
|
788
|
+
return {
|
|
789
|
+
at: at || new Date().toISOString(),
|
|
790
|
+
from: MESSAGE_FROM.includes(from) ? from : 'manager',
|
|
791
|
+
via: MESSAGE_VIA.includes(via) ? via : 'composer',
|
|
792
|
+
text: body.slice(0, MESSAGE_TEXT_LIMIT),
|
|
793
|
+
};
|
|
762
794
|
}
|
|
763
795
|
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
// Japanese has no spaces — splitting on ' ' alone would make the whole
|
|
775
|
-
// spaceless string one giant token and never compare 日本語同士.
|
|
776
|
-
const set = new Set((s.match(/[a-z0-9]{2,}/g) ?? []));
|
|
777
|
-
const cjk = s.replace(/[^-ヿ一-鿿]/g, '');
|
|
778
|
-
for (let i = 0; i < cjk.length - 1; i++) set.add(cjk.slice(i, i + 2));
|
|
779
|
-
return set;
|
|
780
|
-
};
|
|
781
|
-
const sa = toks(na), sb = toks(nb);
|
|
782
|
-
if (!sa.size || !sb.size) return 0;
|
|
783
|
-
let inter = 0;
|
|
784
|
-
for (const t of sa) if (sb.has(t)) inter++;
|
|
785
|
-
return inter / (sa.size + sb.size - inter);
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// Find an existing goal a new goal's text substantially overlaps (near
|
|
789
|
-
// duplicate), so the caller can fold it in as a thread follow-up rather than
|
|
790
|
-
// create a redundant parallel goal. `existingGoals` is expected to be already
|
|
791
|
-
// filtered by the caller to real merge candidates (same project, active,
|
|
792
|
-
// session-bearing). Returns { goal, score } only on HIGH confidence
|
|
793
|
-
// (threshold 0.72); when unsure, null → the caller creates the goal, so a
|
|
794
|
-
// genuinely distinct ask is never silently swallowed. Very short texts
|
|
795
|
-
// (< 6 normalized chars) never match — too little signal to be sure.
|
|
796
|
-
export const GOAL_OVERLAP_THRESHOLD = 0.72;
|
|
797
|
-
export function findOverlappingGoal(text, existingGoals, { threshold = GOAL_OVERLAP_THRESHOLD } = {}) {
|
|
798
|
-
if (normalizeGoalText(text).length < 6) return null;
|
|
799
|
-
let best = null, bestScore = 0;
|
|
800
|
-
for (const g of existingGoals ?? []) {
|
|
801
|
-
if (normalizeGoalText(g?.text).length < 6) continue;
|
|
802
|
-
const score = goalTextSimilarity(text, g?.text);
|
|
803
|
-
if (score > bestScore) { bestScore = score; best = g; }
|
|
804
|
-
}
|
|
805
|
-
return best && bestScore >= threshold ? { goal: best, score: bestScore } : null;
|
|
796
|
+
// JSONL 1行 ⇄ 発言。壊れた行は黙って捨てる(1行の破損で会話全部を失わない)。
|
|
797
|
+
export function serializeGoalMessage(msg) { return JSON.stringify(msg); }
|
|
798
|
+
export function parseGoalMessages(raw) {
|
|
799
|
+
return String(raw ?? '').split('\n').map((line) => {
|
|
800
|
+
if (!line.trim()) return null;
|
|
801
|
+
try {
|
|
802
|
+
const m = JSON.parse(line);
|
|
803
|
+
return (m && typeof m.text === 'string') ? m : null;
|
|
804
|
+
} catch { return null; }
|
|
805
|
+
}).filter(Boolean);
|
|
806
806
|
}
|
|
807
807
|
|
|
808
808
|
// ---- which goal states allow EDIT / DELETE from the board ------------------
|
|
@@ -883,6 +883,24 @@ export function isForcedStop(code, timedOut) {
|
|
|
883
883
|
return Boolean(timedOut) || code === 143 || code === 130;
|
|
884
884
|
}
|
|
885
885
|
|
|
886
|
+
// What a task's status is once the worker has stopped and any check has run.
|
|
887
|
+
//
|
|
888
|
+
// The one thing that changed (Masa 2026-07-22): a check that RAN AND FAILED no
|
|
889
|
+
// longer scores the task 'failed', and (at the call site) no longer sends the
|
|
890
|
+
// worker back for another attempt. Both were how a wrong check turned into real
|
|
891
|
+
// damage — a run measured on 2026-07-22 failed a check that was looking at a
|
|
892
|
+
// file the worker never made, and the rework it triggered spent >100k tokens
|
|
893
|
+
// pushing the work toward that wrong target. A check we authored can be wrong
|
|
894
|
+
// about the work; the work cannot be wrong about itself. So a failing check is
|
|
895
|
+
// now what an inconclusive one already was: a note for the human in review,
|
|
896
|
+
// never a verdict that manufactures a failure. A PASS still wins outright — it
|
|
897
|
+
// is evidence, and evidence beats an exit code.
|
|
898
|
+
export function taskStatusAfterVerify({ verdict, code, timedOut }) {
|
|
899
|
+
if (verdict?.pass) return 'done';
|
|
900
|
+
if (isForcedStop(code, timedOut)) return 'interrupted';
|
|
901
|
+
return code === 0 ? 'done' : 'failed';
|
|
902
|
+
}
|
|
903
|
+
|
|
886
904
|
// A task counts as complete for GOAL status when it cleanly finished
|
|
887
905
|
// (done/skipped) OR it was force-stopped (interrupted) but its change is STRONGLY
|
|
888
906
|
// verified — an independent passCondition verification PASSED (proof.verified &&
|
|
@@ -1404,18 +1422,18 @@ export function groupTasksByGoalOrdered(tasks, goals) {
|
|
|
1404
1422
|
return order.map((key) => ({ goal: key == null ? null : byId.get(key), tasks: map.get(key) }));
|
|
1405
1423
|
}
|
|
1406
1424
|
|
|
1407
|
-
// A goal's
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
return
|
|
1425
|
+
// A goal's number must stay stable while sibling reviews get Approved and drop out of
|
|
1426
|
+
// the list — an index-based "R1/R2/R3" reflows every time one is removed (task 118:
|
|
1427
|
+
// "R2をApproveした後にまた別のがR2になる"). It is the goal's OWN id: unique, permanent,
|
|
1428
|
+
// and the number every screen already prints (app/index.html's own goalReviewNumber has
|
|
1429
|
+
// returned goal.id since the CDO review 2026-07-08 §1).
|
|
1430
|
+
//
|
|
1431
|
+
// This one used to return the goal's lowest task.num instead — so the same ticket was
|
|
1432
|
+
// called #4 by the engine's own digests and #5 by every screen. Measured on the resident
|
|
1433
|
+
// instance 2026-07-22: of 214 goals the two numbers agreed exactly ONCE and differed 184
|
|
1434
|
+
// times. One card, one request, one number (galda-one-card-one-request).
|
|
1435
|
+
export function goalReviewNumber({ goal } = {}) {
|
|
1436
|
+
return goal == null ? null : goal.id;
|
|
1419
1437
|
}
|
|
1420
1438
|
|
|
1421
1439
|
// Review digest (task 227): one row per review-pending goal, collapsed to a
|
|
@@ -1710,11 +1728,114 @@ export function approveGoal({ goalStatus, hasPr = false }) {
|
|
|
1710
1728
|
return { ok: true, status: 'done' };
|
|
1711
1729
|
}
|
|
1712
1730
|
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
|
|
1731
|
+
// 返事の行き先(docs/design/ONE-CONVERSATION.md §1.3)
|
|
1732
|
+
//
|
|
1733
|
+
// Dismiss は長いあいだ一本道だった——何を言っても必ず 'running'(=すぐ作り直し)。
|
|
1734
|
+
// だが人が返す言葉は3種類ある: 「このまま直して」「いったん止めて考え直す」「それは別件」。
|
|
1735
|
+
// 一本道だと後ろ2つが表現できず、考え直したいだけの時にもワーカーが走り出す。
|
|
1736
|
+
//
|
|
1737
|
+
// continue … 今までと同じ。Doing に戻して同じ会話を resume する(既定)
|
|
1738
|
+
// rescope … 止めて考え直す。To Do の下(pending)へ park し、ワーカーは起こさない。
|
|
1739
|
+
// 呼び出し側がゴールの文章そのものを新しい言葉に差し替える
|
|
1740
|
+
// ——チケットの文章が書き換わるのはここだけ
|
|
1741
|
+
// split … 別件。このゴールには触らず、呼び出し側が新しいゴールを作る
|
|
1742
|
+
// answer … 聞いてるだけ。何も動かさない。Review のまま、コードも触らせず、
|
|
1743
|
+
// 作業した AI 本人が読み取り専用で答えるだけ(会話に1発言積む)
|
|
1744
|
+
//
|
|
1745
|
+
// なぜ rescope が 'pending' か: 「走らせずに一覧に戻す」意味を持つ状態はこれだけ。
|
|
1746
|
+
// 'stacked' は空きが出ると自動で走り出す(server の pump)ので「考え直す」にならない。
|
|
1747
|
+
//
|
|
1748
|
+
// なぜ answer が要るか (PRD §5.6・Masa確定 2026-07-22): 返事は中身に関わらず全部
|
|
1749
|
+
// 「直して」に落ちていた——「なんでこうしたの?」と聞いただけでゴールが Doing に
|
|
1750
|
+
// 戻り、ワーカーが起き、コードが変わり始める。聞いただけで作り直しが始まるのは
|
|
1751
|
+
// 壊れている。answer は goalStatus をそのまま返す=1ビットも動かさない。
|
|
1752
|
+
export const REPLY_OUTCOMES = ['continue', 'rescope', 'split', 'answer'];
|
|
1753
|
+
export function dismissGoal({ goalStatus, outcome = 'continue' } = {}) {
|
|
1716
1754
|
if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
|
|
1717
|
-
|
|
1755
|
+
const which = REPLY_OUTCOMES.includes(outcome) ? outcome : 'continue';
|
|
1756
|
+
if (which === 'rescope') return { ok: true, outcome: which, status: 'pending', spawnsWorker: false, rewritesText: true };
|
|
1757
|
+
if (which === 'split') return { ok: true, outcome: which, status: goalStatus, spawnsWorker: false, splits: true };
|
|
1758
|
+
if (which === 'answer') return { ok: true, outcome: which, status: goalStatus, spawnsWorker: false, answers: true };
|
|
1759
|
+
return { ok: true, outcome: 'continue', status: 'running', spawnsWorker: true };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// 誰が決めるか=**AI本人** (docs/design/ONE-CONVERSATION.md §1.4)
|
|
1763
|
+
//
|
|
1764
|
+
// 返事が来た時、それが「このまま直して」なのか「考え直したい」なのか「別件」なのか
|
|
1765
|
+
// 「ただ聞いただけ」なのかを、**我々の層で当てない**。キーワードでも類似度でもなく、
|
|
1766
|
+
// 人の言葉を1回だけ AI に渡して、返ってきた答えのとおりに配線する。0.72 の自動合流を
|
|
1767
|
+
// 消したのと同じ理由——この層が人の意図を推測し始めると、外れた時に黙って別のことを
|
|
1768
|
+
// する(今日そこを1つずつ潰してきた)。
|
|
1769
|
+
//
|
|
1770
|
+
// 迷った時だけ人に聞く: AI が unsure と答えたら**ゴールに触らず**選択肢を返す。人は
|
|
1771
|
+
// モードを普段選ばない。迷った時だけ選ぶ。
|
|
1772
|
+
export const REPLY_INTENTS = [...REPLY_OUTCOMES, 'unsure'];
|
|
1773
|
+
export function buildReplyIntentPrompt({ goalText, reply } = {}) {
|
|
1774
|
+
return [
|
|
1775
|
+
'You route replies in Agent Manager: a person delegated a coding task, an AI did it, and it came back for review. The person has now replied.',
|
|
1776
|
+
'Decide what the reply ASKS FOR — exactly one of:',
|
|
1777
|
+
'- "continue": fix or change the work that was just done (the usual case).',
|
|
1778
|
+
'- "rescope": stop and rethink; the request itself should now say something different.',
|
|
1779
|
+
'- "split": a separate piece of work that should not touch this one.',
|
|
1780
|
+
'- "answer": a question about the work. Nothing should be built or changed — they want to know something.',
|
|
1781
|
+
'- "unsure": you genuinely cannot tell. Prefer this over guessing: the person will be shown the choices.',
|
|
1782
|
+
'Judge the reply on its own words. Do not treat a question mark as proof of "answer", or an imperative as proof of "continue" — "why is this red? make it blue" asks for a change.',
|
|
1783
|
+
'Output ONLY one line of raw JSON: {"outcome":"continue|rescope|split|answer|unsure"}. No markdown, no code fence, no other text.',
|
|
1784
|
+
'',
|
|
1785
|
+
'The request that was worked on:',
|
|
1786
|
+
String(goalText ?? '').slice(0, 2000),
|
|
1787
|
+
'',
|
|
1788
|
+
'Their reply:',
|
|
1789
|
+
String(reply ?? '').slice(0, 4000),
|
|
1790
|
+
].join('\n');
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Tolerant parser — never throws. It returns TWO things, and the difference
|
|
1794
|
+
// between them is the whole point:
|
|
1795
|
+
//
|
|
1796
|
+
// read: true … the AI answered, and we understood it. 'unsure' here means the
|
|
1797
|
+
// AI genuinely could not tell → ask the person (§1.4).
|
|
1798
|
+
// read: false … we could not get an answer at all (no agent installed, offline,
|
|
1799
|
+
// rate-limited, garbled output). The AI never said anything, so
|
|
1800
|
+
// there is nobody to have been unsure.
|
|
1801
|
+
//
|
|
1802
|
+
// Treating those two the same is a real bug, found by an existing test on
|
|
1803
|
+
// 2026-07-22: on a machine with no agent, a reply stopped doing ANYTHING — it put
|
|
1804
|
+
// the four choices on screen and waited. But if the agent cannot be reached, no
|
|
1805
|
+
// answer can be written either; the only useful thing a reply can still do is
|
|
1806
|
+
// what it always did — go back as rework, and run when the agent returns.
|
|
1807
|
+
// So the caller falls back to 'continue' when read is false. That is not a guess
|
|
1808
|
+
// about what the person meant: it is the documented default this endpoint has
|
|
1809
|
+
// always had when no outcome is given.
|
|
1810
|
+
export function parseReplyIntent(raw) {
|
|
1811
|
+
const unread = { outcome: 'unsure', read: false };
|
|
1812
|
+
if (!raw || typeof raw !== 'string') return unread;
|
|
1813
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
1814
|
+
if (!m) return unread;
|
|
1815
|
+
let obj;
|
|
1816
|
+
try { obj = JSON.parse(m[0]); } catch { return unread; }
|
|
1817
|
+
if (!REPLY_INTENTS.includes(obj?.outcome)) return unread; // answered, but not in our words
|
|
1818
|
+
return { outcome: obj.outcome, read: true };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// 宛先(docs/design/ONE-CONVERSATION.md §1.2)
|
|
1822
|
+
//
|
|
1823
|
+
// チャットで「#12 の件だけど」と書けたら、そのチケット宛て。**決定論で、我々の推測は
|
|
1824
|
+
// ゼロ**。これが要るのは、今まで「同じ話かどうか」を我々の層が文字列の似かた(0.72)で
|
|
1825
|
+
// 当てずっぽうに決めていたから——当たれば黙って合流し、外れれば別のカードが生えた。
|
|
1826
|
+
// 人が番号を書けるなら、推測する理由が無い。
|
|
1827
|
+
//
|
|
1828
|
+
// 最初の `#N` だけを見る。番号が実在しなければ宛先なしとして扱う(=ふつうに新しい
|
|
1829
|
+
// チケットになる)ので、`#` を含むだけの文章がどこかへ黙って吸い込まれることはない。
|
|
1830
|
+
// 本文は削らない: 言われた言葉をそのまま作業AIへ渡す(P0 の「原文100%」と同じ約束)。
|
|
1831
|
+
export function parseGoalAddress(text) {
|
|
1832
|
+
// `(?![\w-])` =「番号の直後に英数字やハイフンが続くなら、それは番号ではない」:
|
|
1833
|
+
// `#1f6f4a`(色) や `#12-3` を宛先と読み違えない。日本語は \w に入らないので
|
|
1834
|
+
// 「#12の件だけど」はちゃんと宛先になる。
|
|
1835
|
+
const m = /(?:^|[\s((「【])#(\d+)(?![\w-])/.exec(String(text ?? ''));
|
|
1836
|
+
if (!m) return null;
|
|
1837
|
+
const goalId = Number(m[1]);
|
|
1838
|
+
return Number.isSafeInteger(goalId) && goalId > 0 ? { goalId } : null;
|
|
1718
1839
|
}
|
|
1719
1840
|
|
|
1720
1841
|
// Reject(07-20c・Dismiss とは別物)は自動再作業をせず、Rejected 欄に溜める 'rejected'
|
|
@@ -2342,7 +2463,7 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
|
|
|
2342
2463
|
// it is the one that says how to run it. Requests with nothing to run
|
|
2343
2464
|
// (research, summaries, answers) simply skip this — the worker decides,
|
|
2344
2465
|
// never us.
|
|
2345
|
-
`- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
|
|
2466
|
+
`- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>", "title": "<この作業のチケット名。対象が分かる程度に説明的で動作を含む短い名前。例「看板モードの追加」「ヘッダのズレの修正」。依頼文をそのまま写さない>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
|
|
2346
2467
|
// Links are rendered as links. The product cannot tell a real one from a
|
|
2347
2468
|
// decorative one, so the only place this can be held is here.
|
|
2348
2469
|
`- 自分が作っていない場所へのリンクを報告に書かない(存在しないURLや「詳しくはここ」のような飾りのリンクは、そのままリンクとして人に表示される)。`,
|
|
@@ -2357,7 +2478,11 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
|
|
|
2357
2478
|
// anyway is not a harmless extra line: the Codex worker tried four times,
|
|
2358
2479
|
// then reported the failure instead of the work. Do not ask an agent for
|
|
2359
2480
|
// 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
|
|
2481
|
+
agentName === 'codex' ? '' : `- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>] [--element <CSSセレクタ>] [--clip x,y,幅,高さ]\`(headless Chrome・前面に出ない)。変えた場所が小さいなら --element / --clip でそこだけ撮ってよい(ページ全体を撮って「どこが違うか探して」にしない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
|
|
2482
|
+
// The pair, declared by the only party that can know it. Deliberately not
|
|
2483
|
+
// "take a before shot before you start": that would be this layer deciding
|
|
2484
|
+
// the request is a change-something request, and most requests are not.
|
|
2485
|
+
agentName === 'codex' ? '' : `- 同じ画面の「変える前」と「変えた後」を撮ったなら、${RUN_DECL_FILE} に \`"compare": ["<前の画像>.png", "<後の画像>.png"]\` と書いてよい(その2枚が同じ場所の前後だと分かるのは撮った本人だけ)。人には並べて、違う部分に印を付けて見せる。3案を並べた等、前後の関係が無い画像には書かない。`,
|
|
2361
2486
|
`- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
|
|
2362
2487
|
].filter(Boolean).join('\n');
|
|
2363
2488
|
}
|
|
@@ -2383,15 +2508,42 @@ export function parseRunDeclaration(text) {
|
|
|
2383
2508
|
const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
|
|
2384
2509
|
const cmd = str(raw.cmd, 500);
|
|
2385
2510
|
const url = str(raw.url, 500);
|
|
2386
|
-
//
|
|
2387
|
-
|
|
2511
|
+
// `compare` (Masa 2026-07-22): which TWO of the worker's own screenshots are
|
|
2512
|
+
// the same view before and after. Only the agent that did the work knows that
|
|
2513
|
+
// two images relate — a request for three design options has no "after" at
|
|
2514
|
+
// all — so this layer must never infer it. (The app briefly guessed the pair
|
|
2515
|
+
// from file names containing "before"/"after": the same mistake as the intake
|
|
2516
|
+
// AI writing a pass condition, and it is not repeated here.)
|
|
2517
|
+
const compare = parseComparePair(raw.compare);
|
|
2518
|
+
const title = str(raw.title, 60);
|
|
2519
|
+
// Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
|
|
2520
|
+
if (!cmd && !url && !compare && !title) return null;
|
|
2388
2521
|
// Only http(s): the button opens this in the user's browser.
|
|
2389
2522
|
if (url && !/^https?:\/\//i.test(url)) return null;
|
|
2390
2523
|
// `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
|
|
2391
2524
|
// to come from the intake AI's plan — this layer inventing a pass condition for a
|
|
2392
2525
|
// request it only read. The AI that did the work states it here instead, in the same
|
|
2393
2526
|
// file it already writes, and the review card shows that sentence verbatim.
|
|
2394
|
-
|
|
2527
|
+
// `title` (Masa 2026-07-22): the ticket name, from the AI that did the work.
|
|
2528
|
+
// Until now the name came from a summarizer LLM down here, and when that call
|
|
2529
|
+
// returned nothing the card fell back to the raw request — so the "title" was
|
|
2530
|
+
// the user's own sentence, repeated back at them in the one place that is
|
|
2531
|
+
// supposed to say what the work WAS. The agent that just did it can name it in
|
|
2532
|
+
// one line, and it is the only party that knows what actually got done.
|
|
2533
|
+
return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// Exactly two shot file names, in [before, after] order. Anything else is
|
|
2537
|
+
// dropped rather than half-accepted: the card draws a red box over the region
|
|
2538
|
+
// that differs between the two, and a box drawn over the wrong pair is the
|
|
2539
|
+
// fabricated-evidence bug again. Names only — a path would let a declaration
|
|
2540
|
+
// point outside the shots the Manager collected.
|
|
2541
|
+
export function parseComparePair(value) {
|
|
2542
|
+
if (!Array.isArray(value) || value.length !== 2) return null;
|
|
2543
|
+
const names = value.map((v) => (typeof v === 'string' ? v.trim() : '')).map((n) => n.replace(/^.*[/\\]/, ''));
|
|
2544
|
+
if (names.some((n) => !n || n.length > 120)) return null;
|
|
2545
|
+
if (names[0] === names[1]) return null; // a picture compared with itself shows nothing
|
|
2546
|
+
return names;
|
|
2395
2547
|
}
|
|
2396
2548
|
|
|
2397
2549
|
// ---- SHOTS: the worker's own camera ----------------------------------------
|
|
@@ -2439,11 +2591,11 @@ export function pickChromePath(env = {}, exists = () => false) {
|
|
|
2439
2591
|
}
|
|
2440
2592
|
|
|
2441
2593
|
// `node <manager>/engine/shot.mjs <url|file> <out.png> [--width N] [--height N]
|
|
2442
|
-
// [--full] [--wait MS] [--wait-for SELECTOR]`
|
|
2594
|
+
// [--full] [--wait MS] [--wait-for SELECTOR] [--element SELECTOR] [--clip x,y,w,h]`
|
|
2443
2595
|
// Returns { error } instead of throwing, so the CLI can print one clear line.
|
|
2444
2596
|
export function parseShotArgs(argv = []) {
|
|
2445
2597
|
const rest = [];
|
|
2446
|
-
const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null };
|
|
2598
|
+
const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null, element: null, clip: null };
|
|
2447
2599
|
const num = (v, lo, hi) => {
|
|
2448
2600
|
const n = Number(v);
|
|
2449
2601
|
return Number.isFinite(n) && n >= lo && n <= hi ? Math.round(n) : null;
|
|
@@ -2466,12 +2618,33 @@ export function parseShotArgs(argv = []) {
|
|
|
2466
2618
|
if (!sel) return { error: '--wait-for needs a CSS selector' };
|
|
2467
2619
|
opt.waitFor = sel; continue;
|
|
2468
2620
|
}
|
|
2621
|
+
// Region (Masa 2026-07-22). A full-page picture of a one-component change
|
|
2622
|
+
// hands the reviewer a page and asks them to find the difference — and the
|
|
2623
|
+
// agent that made the change is the only one who knows where to point. Two
|
|
2624
|
+
// ways in: a selector when the worker has one, coordinates when it doesn't.
|
|
2625
|
+
if (a === '--element') {
|
|
2626
|
+
const sel = String(argv[++i] ?? '').trim();
|
|
2627
|
+
if (!sel) return { error: '--element needs a CSS selector' };
|
|
2628
|
+
opt.element = sel; continue;
|
|
2629
|
+
}
|
|
2630
|
+
if (a === '--clip') {
|
|
2631
|
+
const parts = String(argv[++i] ?? '').split(',').map((s) => s.trim());
|
|
2632
|
+
if (parts.length !== 4) return { error: '--clip needs x,y,width,height (e.g. --clip 0,0,800,400)' };
|
|
2633
|
+
const [x, y, width, height] = parts.map((v, idx) => num(v, idx < 2 ? 0 : 1, 20000));
|
|
2634
|
+
if ([x, y, width, height].some((n) => n == null)) return { error: '--clip needs four numbers: x,y,width,height' };
|
|
2635
|
+
opt.clip = { x, y, width, height }; continue;
|
|
2636
|
+
}
|
|
2469
2637
|
if (a.startsWith('--')) return { error: `unknown option ${a}` };
|
|
2470
2638
|
rest.push(a);
|
|
2471
2639
|
}
|
|
2472
2640
|
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]' };
|
|
2641
|
+
if (!target || !out) return { error: 'usage: node engine/shot.mjs <url|file> <out.png> [--width N] [--height N] [--full] [--wait MS] [--wait-for SELECTOR] [--element SELECTOR] [--clip x,y,w,h]' };
|
|
2474
2642
|
if (!/\.png$/i.test(out)) return { error: 'output must end in .png' };
|
|
2643
|
+
// One region, not two — a selector and a rectangle disagree the moment the
|
|
2644
|
+
// page reflows, and silently preferring one would make the picture a guess.
|
|
2645
|
+
if (opt.element && opt.clip) return { error: 'use --element or --clip, not both' };
|
|
2646
|
+
// A region IS the framing, so --full has nothing left to mean.
|
|
2647
|
+
if (opt.full && (opt.element || opt.clip)) return { error: '--full cannot be combined with --element/--clip' };
|
|
2475
2648
|
return { target, out, ...opt };
|
|
2476
2649
|
}
|
|
2477
2650
|
|
|
@@ -3517,6 +3690,15 @@ export function needsProjectFolder({ dir, homeDir, isRepo } = {}) {
|
|
|
3517
3690
|
return !isRepo;
|
|
3518
3691
|
}
|
|
3519
3692
|
|
|
3693
|
+
// The repo's own name: the last segment of its path. The composer chip prints this and
|
|
3694
|
+
// the folder menu prints it again on every row, so it lives here as one function — two
|
|
3695
|
+
// copies of "take the last segment" is how a chip and its own menu start disagreeing
|
|
3696
|
+
// (CDO handoff 2026-07-23 §A2). Trailing slashes are the caller's typo, not a segment.
|
|
3697
|
+
export function folderName(dir) {
|
|
3698
|
+
const d = String(dir ?? '').replace(/\/+$/, '');
|
|
3699
|
+
return d ? d.split('/').pop() : '';
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3520
3702
|
// A short, ≤60-char display label for a repo dir, shown home-relative (~/…) so
|
|
3521
3703
|
// the folder-choice options read cleanly on the board card.
|
|
3522
3704
|
export function folderLabel(dir, homeDir = '') {
|