@galda/cli 0.10.35 → 0.10.37
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 +330 -66
- package/engine/lib.mjs +103 -12
- package/engine/server.mjs +199 -31
- package/package.json +1 -1
package/engine/lib.mjs
CHANGED
|
@@ -877,6 +877,42 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
|
|
|
877
877
|
].join('\n');
|
|
878
878
|
}
|
|
879
879
|
|
|
880
|
+
// summarizeForReview's prompt + reply parser, kept pure/here so engine/test can
|
|
881
|
+
// assert (a) the JSON asks for a work-title, (b) the title guidance names
|
|
882
|
+
// 修正/追加/改善/調査 so an "追加したい" request never becomes "…の修正", and (c) a
|
|
883
|
+
// failed/missing reply falls back to goal.text (no regression) — all without an
|
|
884
|
+
// LLM call. The app's saReviewTitle reads reviewSummary.title first (PR-B).
|
|
885
|
+
export function buildReviewSummaryPrompt({ lang, goalText, changed, reports }) {
|
|
886
|
+
return [
|
|
887
|
+
`あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で書いてください。`,
|
|
888
|
+
'専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
|
|
889
|
+
'{"title":"依頼を短い作業タイトルに言い換え(体言止め可)","check":"何を確認すれば良いか(1行)","changed":"何を変えたか(1行)"}',
|
|
890
|
+
'titleは依頼文の意図を取り違えないこと。修正/追加/改善/調査を区別する。'
|
|
891
|
+
+ '例:「Xの不具合」→「Xの修正」/「Yを追加したい」→「Yの追加」/「Zを速くしたい」→「Zの改善」/「なぜWか調べて」→「Wの調査」。',
|
|
892
|
+
'',
|
|
893
|
+
`ゴール: ${(goalText ?? '').slice(0, 300)}`,
|
|
894
|
+
`変更ファイル: ${(changed ?? []).join(', ') || '(なし)'}`,
|
|
895
|
+
`ワーカー報告:\n${(reports ?? '').slice(0, 1500)}`,
|
|
896
|
+
].join('\n');
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Parse the model's JSON into {title, check, changed}, per-field fallback when a
|
|
900
|
+
// key is missing/blank. `fallback` carries the text-derived defaults
|
|
901
|
+
// (title/changed = goal.text) so a goal whose summary call failed still shows
|
|
902
|
+
// its own request text. Never throws — a broken/absent JSON yields the fallback.
|
|
903
|
+
export function parseReviewSummary(raw, fallback = {}) {
|
|
904
|
+
const clean = (v, n) => String(v ?? '').replace(/\s+/g, ' ').slice(0, n);
|
|
905
|
+
const m = String(raw ?? '').match(/\{[\s\S]*\}/);
|
|
906
|
+
if (!m) return { ...fallback };
|
|
907
|
+
let o;
|
|
908
|
+
try { o = JSON.parse(m[0]); } catch { return { ...fallback }; }
|
|
909
|
+
return {
|
|
910
|
+
title: clean(o.title, 60) || fallback.title,
|
|
911
|
+
check: clean(o.check, 120),
|
|
912
|
+
changed: clean(o.changed, 120) || fallback.changed,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
880
916
|
// A finished task's changed files look like a UI edit: app/ (the Manager's
|
|
881
917
|
// own UI dir, or an equivalent frontend root) plus common frontend source
|
|
882
918
|
// extensions. Pure/deterministic — no LLM — so it can gate proof capture
|
|
@@ -1139,6 +1175,7 @@ export function goalGroupStatus(goalStatus, startedAt) {
|
|
|
1139
1175
|
if (goalStatus === 'folded') return 'review'; // PRD §6.5: rendered inside the Review section, not Done
|
|
1140
1176
|
if (goalStatus === 'blocked') return 'attn'; // §4E: blocked is Attention, not Done
|
|
1141
1177
|
if (goalStatus === 'needsInput') return 'todo'; // smart intake: awaiting an answer
|
|
1178
|
+
if (goalStatus === 'rejected') return 'rejected'; // 07-20c: Reject を溜める独立欄(Done ではない)
|
|
1142
1179
|
return 'done'; // includes 'reverted' (terminal)
|
|
1143
1180
|
}
|
|
1144
1181
|
|
|
@@ -1375,6 +1412,22 @@ export function classifyComposerIntentHeuristic(text) {
|
|
|
1375
1412
|
return 'unsure'; // both or neither → let the LLM decide
|
|
1376
1413
|
}
|
|
1377
1414
|
|
|
1415
|
+
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). A DETERMINISTIC,
|
|
1416
|
+
// conservative match — no LLM, no cost — that only fires when the WHOLE message
|
|
1417
|
+
// is a short, command-like pause/resume order. Anchored ^…$ with only polite
|
|
1418
|
+
// suffixes allowed after the keyword, plus a length cap, so a described request
|
|
1419
|
+
// that merely contains the word ("止めてほしいバグを直して", "start building the
|
|
1420
|
+
// login") is never mistaken for a control command. Returns 'pause'|'resume'|null.
|
|
1421
|
+
const PAUSE_INTENT_RE = /^(一時停止|いったん停止|一旦停止|停止|止めて|とめて|ストップ|作業を?止めて|全部止めて|全部停止|pause|stop)(?:\s*(して|してください|ください|でお願いします?|お願いします?|please|now))?[\s。.!!、,]*$/i;
|
|
1422
|
+
const RESUME_INTENT_RE = /^(再開|再生|再スタート|続けて|続きから|続行|resume|restart|start|play|go)(?:\s*(して|してください|ください|お願いします?|please|now))?[\s。.!!、,]*$/i;
|
|
1423
|
+
export function detectPauseIntent(text) {
|
|
1424
|
+
const t = (text || '').trim();
|
|
1425
|
+
if (!t || t.length > 24) return null; // a control command is short; long text is a real request
|
|
1426
|
+
if (PAUSE_INTENT_RE.test(t)) return 'pause';
|
|
1427
|
+
if (RESUME_INTENT_RE.test(t)) return 'resume';
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1378
1431
|
// The one prompt that BOTH classifies and (for chat) writes the reply in the
|
|
1379
1432
|
// requester's language — one Haiku round-trip, not two. The model returns a
|
|
1380
1433
|
// single line of JSON: {"intent":"chat|task","reply":"…"}.
|
|
@@ -1479,6 +1532,8 @@ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = fals
|
|
|
1479
1532
|
// 人がアプリ側から閉じる経路。
|
|
1480
1533
|
export function advanceReviewGoal(goal) {
|
|
1481
1534
|
if (goal.status !== 'review') return { ok: false, error: 'goal is not in review' };
|
|
1535
|
+
// 07-20c: PR ありゴールは Approve だけで done にしない(merge=採用が唯一の完了トリガー)。
|
|
1536
|
+
if (goal && goal.pr) return { ok: true, needsMerge: true, status: 'review' };
|
|
1482
1537
|
return { ok: true, status: 'done' };
|
|
1483
1538
|
}
|
|
1484
1539
|
|
|
@@ -1492,20 +1547,39 @@ export function revertReviewGoal(goal) {
|
|
|
1492
1547
|
// レビュー詳細パネル(task 70)のApprove/Dismissボタンの判定。advanceReviewGoal(task 62,
|
|
1493
1548
|
// チェックリスト全件Approveの自動進行)とは別に、人間がレビュー画面から単発で下す判断
|
|
1494
1549
|
// を扱う——PRの有無を問わず、レビュー中のgoalだけが対象。
|
|
1495
|
-
// Approve
|
|
1496
|
-
//
|
|
1497
|
-
|
|
1550
|
+
// Approve は PR の有無で分岐する(07-20c)。PR 無しは従来どおり即 done。PR ありは
|
|
1551
|
+
// Approve だけで done にせず needsMerge を返す——呼び出し側が「PR をマージして完了」
|
|
1552
|
+
// ダイアログを出して PR へ飛ばし、マージ後に syncGoalMerges が review→done を自動確定
|
|
1553
|
+
// する。これで「アプリで Approve 済みだが PR は開いたまま」のゾンビを構造的に消す
|
|
1554
|
+
// (merge=採用は人の操作、の鉄則を守る)。
|
|
1555
|
+
export function approveGoal({ goalStatus, hasPr = false }) {
|
|
1498
1556
|
if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
|
|
1557
|
+
if (hasPr) return { ok: true, needsMerge: true, status: 'review' };
|
|
1499
1558
|
return { ok: true, status: 'done' };
|
|
1500
1559
|
}
|
|
1501
1560
|
|
|
1502
|
-
// Dismiss(差し戻し)は 'running'
|
|
1503
|
-
// startedAt済みなら'doing'
|
|
1561
|
+
// Dismiss(差し戻し)は 'running' に戻す——チャット(/reply や /dismiss にテキスト)での
|
|
1562
|
+
// 「作り直し」依頼の受け皿。goalGroupStatus は startedAt 済みなら 'doing' に読む。
|
|
1504
1563
|
export function dismissGoal({ goalStatus }) {
|
|
1505
1564
|
if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
|
|
1506
1565
|
return { ok: true, status: 'running' };
|
|
1507
1566
|
}
|
|
1508
1567
|
|
|
1568
|
+
// Reject(07-20c・Dismiss とは別物)は自動再作業をせず、Rejected 欄に溜める 'rejected'
|
|
1569
|
+
// へ park する。作り直しはチャット(/reply)で依頼する。Rejected からは reopenGoal で
|
|
1570
|
+
// review へ戻せ、Delete(=revert)で PR ごと破棄できる。/dismiss(rework)とは別の
|
|
1571
|
+
// /reject エンドポイントが担うので、既存のチャット rework 契約を壊さない。
|
|
1572
|
+
export function rejectGoal({ goalStatus }) {
|
|
1573
|
+
if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
|
|
1574
|
+
return { ok: true, status: 'rejected' };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Rejected 欄の『Reopen』——rejected を review に戻す(rejectGoal の逆操作)。
|
|
1578
|
+
export function reopenGoal({ goalStatus }) {
|
|
1579
|
+
if (goalStatus !== 'rejected') return { ok: false, error: 'goal is not rejected' };
|
|
1580
|
+
return { ok: true, status: 'review' };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1509
1583
|
// ---- MODE (Auto / Plan) ----------------------------------------------------
|
|
1510
1584
|
// A goal runs in one of two modes. 'auto' = the default implement→verify→review
|
|
1511
1585
|
// flow (worker edits files with acceptEdits). 'plan' = the worker runs under
|
|
@@ -2445,6 +2519,8 @@ export function goalLogLine(goal, prevGoal) {
|
|
|
2445
2519
|
const label = String(goal?.plan?.[0] ?? goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
2446
2520
|
if (!prevGoal) return goal?.status === 'pending' ? `Goal shelved to Pending: "${label}"` : null;
|
|
2447
2521
|
if (prevGoal.status === 'review' && goal.status === 'done') return `Approved: "${label}"`;
|
|
2522
|
+
if (prevGoal.status === 'review' && goal.status === 'rejected') return `Rejected: "${label}"`;
|
|
2523
|
+
if (prevGoal.status === 'rejected' && goal.status === 'review') return `Reopened: "${label}"`;
|
|
2448
2524
|
if (prevGoal.status === 'review' && goal.status === 'running') return `Dismissed: "${label}" — back to work`;
|
|
2449
2525
|
if (prevGoal.status !== 'running' && goal.status === 'running' && Array.isArray(goal.plan) && goal.plan.length) {
|
|
2450
2526
|
return `Goal decomposed into ${goal.plan.length} task(s) → To do: "${label}"`;
|
|
@@ -2548,8 +2624,10 @@ export function computeRetestOutcome(results = [], total = 3) {
|
|
|
2548
2624
|
// terminal 'reverted' status (distinct from 'skipped'/archived). Decision only —
|
|
2549
2625
|
// the destructive gh/git calls live behind the server's small wrapper.
|
|
2550
2626
|
export function revertGoal({ goalStatus }) {
|
|
2551
|
-
|
|
2552
|
-
|
|
2627
|
+
// 07-20c: 'rejected' も対象に含める——Rejected 欄の Delete は revert 経由で PR を閉じ
|
|
2628
|
+
// worktree を落とす(PR を残したまま消すと孤児 PR =別のゾンビになるため)。
|
|
2629
|
+
if (!['review', 'done', 'blocked', 'partial', 'rejected'].includes(goalStatus)) {
|
|
2630
|
+
return { ok: false, error: 'only a finished (review/done/blocked/partial/rejected) goal can be reverted' };
|
|
2553
2631
|
}
|
|
2554
2632
|
return { ok: true, status: 'reverted' };
|
|
2555
2633
|
}
|
|
@@ -2887,8 +2965,11 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
|
|
|
2887
2965
|
// tested — sharing a module across that boundary would couple two things
|
|
2888
2966
|
// that fail and deploy on different schedules. Keep the two copies in sync by
|
|
2889
2967
|
// hand; both have their own test coverage against the same free-tier numbers.
|
|
2968
|
+
// maxPendingGoals (the 5 concurrent "queued to-do" cap) was REMOVED 2026-07-20
|
|
2969
|
+
// (Masa): the free tier no longer caps how many to-dos run at once, and the UI
|
|
2970
|
+
// drops the "x / 5" counter. The lifetime (20) and project (1) caps remain the
|
|
2971
|
+
// monetization wall.
|
|
2890
2972
|
export const FREE_TIER_LIMITS = Object.freeze({
|
|
2891
|
-
maxPendingGoals: 5,
|
|
2892
2973
|
maxCumulativeGoals: 20,
|
|
2893
2974
|
maxProjects: 1,
|
|
2894
2975
|
});
|
|
@@ -2905,12 +2986,10 @@ export const QUEUED_GOAL_STATUSES = Object.freeze([
|
|
|
2905
2986
|
]);
|
|
2906
2987
|
|
|
2907
2988
|
export function checkFreeTierLimit(usage) {
|
|
2908
|
-
const pendingCount = Number(usage?.pendingCount) || 0;
|
|
2909
2989
|
const cumulativeCount = Number(usage?.cumulativeCount) || 0;
|
|
2910
2990
|
const projectCount = Number(usage?.projectCount) || 0;
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
}
|
|
2991
|
+
// Concurrent "pending" to-dos are no longer capped (Masa 2026-07-20) — only
|
|
2992
|
+
// the lifetime total and the project count gate the free tier.
|
|
2914
2993
|
if (cumulativeCount > FREE_TIER_LIMITS.maxCumulativeGoals) {
|
|
2915
2994
|
return { blocked: true, reason: 'cumulative-goal-limit', limit: FREE_TIER_LIMITS.maxCumulativeGoals, value: cumulativeCount };
|
|
2916
2995
|
}
|
|
@@ -3493,3 +3572,15 @@ export function parseClaimResponse(obj) {
|
|
|
3493
3572
|
if (obj.token) return { state: 'ready', token: String(obj.token), email: obj.email != null ? String(obj.email) : undefined };
|
|
3494
3573
|
return { state: 'pending' };
|
|
3495
3574
|
}
|
|
3575
|
+
|
|
3576
|
+
// --- connection status line (queue tray) -------------------------------------
|
|
3577
|
+
// Pure source of truth for the offline/reconnecting line that renders as the top
|
|
3578
|
+
// row of the queued tray above the composer. app/index.html is a browser static
|
|
3579
|
+
// file that can't import this module, so it MIRRORS these two tiny functions
|
|
3580
|
+
// inline (see renderQueue()); keep the logic identical on both sides.
|
|
3581
|
+
export function connectionBannerState({ online }) {
|
|
3582
|
+
return online ? { kind: 'ok' } : { kind: 'offline', reconnect: true };
|
|
3583
|
+
}
|
|
3584
|
+
export function unsentMetaLabel({ online }) {
|
|
3585
|
+
return online ? 'Unsent — tap to resend' : 'Waiting for connection';
|
|
3586
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
|
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
23
|
import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
|
|
25
25
|
import { openPR } from './pr.mjs';
|
|
26
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
27
27
|
|
|
@@ -657,11 +657,17 @@ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
|
|
|
657
657
|
const goalId = nextId++, taskId = nextId++;
|
|
658
658
|
goals.push({
|
|
659
659
|
id: goalId, projectId: 'default', text: '[verify fixture] 実行中ゴール', status: 'running',
|
|
660
|
+
// startedAt is what a REAL running goal carries (server.mjs sets it when the
|
|
661
|
+
// first task starts — see runTask). Without it the board bucketed this as
|
|
662
|
+
// To Do while the list showed it under Doing (task-level) — the two views
|
|
663
|
+
// disagreed on a malformed fixture. Set it so the seed matches reality:
|
|
664
|
+
// both views place a running goal in Doing (Masa 2026-07-19).
|
|
665
|
+
startedAt: at,
|
|
660
666
|
wantsPR: false, model: 'sonnet', images: [], plan: ['fixture task'], pr: undefined, createdAt: at,
|
|
661
667
|
});
|
|
662
668
|
tasks.push({
|
|
663
669
|
id: taskId, num: 1, goalId, projectId: 'default', title: 'fixture task', detail: '', passCondition: '',
|
|
664
|
-
model: 'sonnet', status: 'running', createdAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
|
|
670
|
+
model: 'sonnet', status: 'running', createdAt: at, startedAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
|
|
665
671
|
activity: [
|
|
666
672
|
'Read app/index.html', 'Grep taskBlock', 'Edit app/index.html', 'Read engine/lib.mjs',
|
|
667
673
|
'Bash node --test engine/test/*.test.mjs', '✓ 12 tests passed', 'Edit engine/server.mjs',
|
|
@@ -1302,12 +1308,22 @@ function globalRunningCount() {
|
|
|
1302
1308
|
return n;
|
|
1303
1309
|
}
|
|
1304
1310
|
|
|
1311
|
+
// Per-project play/pause (Masa: Doing列の一時停止/再生). A paused project neither
|
|
1312
|
+
// starts new stacked goals nor pumps queued tasks — the two choke points below
|
|
1313
|
+
// (startNextStacked / pump) read this, so EVERY caller of them is covered by one
|
|
1314
|
+
// guard each. The flag lives on the project object (projects.json), so it
|
|
1315
|
+
// survives a restart; nothing auto-resumes a paused project.
|
|
1316
|
+
function isProjectPaused(projectId) {
|
|
1317
|
+
return !!projects.find((p) => p.id === projectId)?.paused;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1305
1320
|
// Goals stack up per project: while one goal is being planned or executed,
|
|
1306
1321
|
// newer goals wait as 'stacked' (editable / deletable until picked up).
|
|
1307
1322
|
function goalActive(projectId) {
|
|
1308
1323
|
return goals.some((g) => g.projectId === projectId && ['planning', 'running'].includes(g.status));
|
|
1309
1324
|
}
|
|
1310
1325
|
function startNextStacked(projectId) {
|
|
1326
|
+
if (isProjectPaused(projectId)) return; // paused → don't begin planning queued goals
|
|
1311
1327
|
if (goalActive(projectId)) return;
|
|
1312
1328
|
const next = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
|
|
1313
1329
|
.sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id)[0];
|
|
@@ -1496,6 +1512,9 @@ function pump(projectId) {
|
|
|
1496
1512
|
// to sign `claude` in and relaunch). Never spawn a worker that can only fail
|
|
1497
1513
|
// with "Not logged in". Cleared automatically once a probe succeeds.
|
|
1498
1514
|
if (!workerAuth.ok) return;
|
|
1515
|
+
// Project paused by the user (Doing列の一時停止 / chat) → hold this project's
|
|
1516
|
+
// queue exactly like the auth hold above. Resume flips the flag and pumps.
|
|
1517
|
+
if (isProjectPaused(projectId)) return;
|
|
1499
1518
|
const q = queues.get(projectId);
|
|
1500
1519
|
// sortQueueByPriority is a stable sort: it only ever moves a task ahead of
|
|
1501
1520
|
// a lower-priority one, never disturbs relative order within the same
|
|
@@ -1729,6 +1748,48 @@ function rateLimitResumeSweep() {
|
|
|
1729
1748
|
const rateLimitSweepTimer = setInterval(rateLimitResumeSweep, RATE_LIMIT_SWEEP_MS);
|
|
1730
1749
|
rateLimitSweepTimer.unref?.();
|
|
1731
1750
|
|
|
1751
|
+
// Project play/pause (Masa: Doing列の一時停止ボタン + チャット). Pause STOPS the
|
|
1752
|
+
// project's in-flight Doing work and prevents new ToDo from starting; Resume
|
|
1753
|
+
// continues from where it left off. Modelled on the rate-limit pause above (a
|
|
1754
|
+
// SIGTERM'd worker becomes an 'interrupted' task with its sessionId preserved,
|
|
1755
|
+
// so runTask's retry/resume path re-continues the same conversation) — the only
|
|
1756
|
+
// differences are: it's user-driven (no resumeAt / auto-resume), and it flips
|
|
1757
|
+
// the persisted project.paused flag that gates pump()/startNextStacked().
|
|
1758
|
+
//
|
|
1759
|
+
// Race note: setting goal.status='blocked' HERE (synchronously) BEFORE the
|
|
1760
|
+
// SIGTERM'd worker's async exit runs means finishGoalIfComplete later sees a
|
|
1761
|
+
// non-'running'/'partial' goal and bails at its status guard without clobbering
|
|
1762
|
+
// the paused state — see finishGoalIfComplete's early return.
|
|
1763
|
+
function pauseProject(projectId) {
|
|
1764
|
+
const project = projects.find((p) => p.id === projectId);
|
|
1765
|
+
if (!project || project.paused) return false;
|
|
1766
|
+
project.paused = true;
|
|
1767
|
+
saveProjects();
|
|
1768
|
+
for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'running')) {
|
|
1769
|
+
killGoalWorkers(g.id); // SIGTERM → task becomes 'interrupted' (resumable), not cancelled
|
|
1770
|
+
g.status = 'blocked';
|
|
1771
|
+
g.blocked = { kind: 'paused', reason: 'paused by user — resume to continue' };
|
|
1772
|
+
saveGoal(g);
|
|
1773
|
+
}
|
|
1774
|
+
send({ ev: 'projects', projects });
|
|
1775
|
+
return true;
|
|
1776
|
+
}
|
|
1777
|
+
function resumeProject(projectId) {
|
|
1778
|
+
const project = projects.find((p) => p.id === projectId);
|
|
1779
|
+
if (!project || !project.paused) return false;
|
|
1780
|
+
project.paused = false;
|
|
1781
|
+
saveProjects();
|
|
1782
|
+
// Re-queue each paused goal's interrupted tasks and flip it back to running
|
|
1783
|
+
// (reuses the proven rate-limit resume: continues from the preserved session).
|
|
1784
|
+
for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'blocked' && g.blocked?.kind === 'paused')) {
|
|
1785
|
+
resumeRateLimitedGoal(g);
|
|
1786
|
+
}
|
|
1787
|
+
startNextStacked(projectId);
|
|
1788
|
+
pump(projectId);
|
|
1789
|
+
send({ ev: 'projects', projects });
|
|
1790
|
+
return true;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1732
1793
|
// Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
|
|
1733
1794
|
// runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
|
|
1734
1795
|
function queueReplyTask(goal, text) {
|
|
@@ -2365,31 +2426,66 @@ async function runProjectTests(dir) {
|
|
|
2365
2426
|
// Failing tests on the BASE (pre-change) checkout — the set the review gate
|
|
2366
2427
|
// subtracts so a goal is blocked only on failures IT introduced, never on a
|
|
2367
2428
|
// test that was already red (e.g. persist-failure on clean origin/main). Lazy
|
|
2368
|
-
// + cached by the base
|
|
2369
|
-
//
|
|
2370
|
-
//
|
|
2429
|
+
// + cached by the base-ref sha in MANAGER_HOME: computed only when a goal's own
|
|
2430
|
+
// run is red, then reused across goals sharing the same base (main moves a few
|
|
2431
|
+
// times a day, so this is a handful of full runs a day, not one per goal).
|
|
2371
2432
|
// Any failure to compute → [] = "unknown", and the caller falls back to the
|
|
2372
2433
|
// old block-on-any-red behaviour (fail closed).
|
|
2434
|
+
//
|
|
2435
|
+
// 🔴 The base MUST be the ref the goal worktree was branched from —
|
|
2436
|
+
// origin/<default> (goalWorkDir: `git worktree add … origin/<br>`), NOT the
|
|
2437
|
+
// registry dir's working HEAD. Those diverge in the wild: a dogfood checkout
|
|
2438
|
+
// left on an old branch, or uncommitted local work. Measuring the baseline on a
|
|
2439
|
+
// stale HEAD runs a suite where today's red tests don't exist yet → an EMPTY
|
|
2440
|
+
// failing set → the gate falls closed and blocks EVERY goal on a red that was
|
|
2441
|
+
// actually pre-existing. (Masa 2026-07-19: the registry dir sat 612 commits
|
|
2442
|
+
// behind origin/main, so every goal Failed on "テスト失敗 1件" the baseline
|
|
2443
|
+
// should have excused.) So resolve origin/<default> and run the base suite in a
|
|
2444
|
+
// throwaway detached worktree of that exact commit — nested under
|
|
2445
|
+
// repoRoot/.manager-wt so node's upward node_modules resolution still finds the
|
|
2446
|
+
// repo's deps (the same trick goal worktrees rely on), and so we never mutate
|
|
2447
|
+
// the registry checkout (prod procs read it; it may be on any branch).
|
|
2373
2448
|
const BASELINE_CACHE = join(DATA_DIR, 'test-baseline.json');
|
|
2449
|
+
const baselineInFlight = new Map(); // sha -> Promise<string[]> — dedupe concurrent goal closes
|
|
2374
2450
|
async function getBaselineFailures(baseProject) {
|
|
2375
2451
|
const dir = baseProject?.dir;
|
|
2376
2452
|
if (!dir) return [];
|
|
2377
|
-
let
|
|
2378
|
-
try {
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2453
|
+
let repoRoot;
|
|
2454
|
+
try { repoRoot = gitSync(dir, ['rev-parse', '--show-toplevel']); } catch { return []; }
|
|
2455
|
+
const br = repoDefaultBranch(dir);
|
|
2456
|
+
try { gitSync(repoRoot, ['fetch', 'origin', br, '--quiet']); } catch { /* offline — use whatever origin/<br> we already have */ }
|
|
2457
|
+
// The base ref the goal worktree is cut from: origin/<br>, else the local
|
|
2458
|
+
// branch tip, else the working HEAD (offline single-checkout fallback).
|
|
2459
|
+
const sha = (gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${br}`])
|
|
2460
|
+
|| gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', br])
|
|
2461
|
+
|| gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD']) || '').trim();
|
|
2382
2462
|
if (!sha) return [];
|
|
2383
2463
|
try {
|
|
2384
2464
|
const cached = JSON.parse(readFileSync(BASELINE_CACHE, 'utf8'));
|
|
2385
2465
|
if (cached && cached.sha === sha && Array.isArray(cached.failingNames)) return cached.failingNames;
|
|
2386
2466
|
} catch { /* no cache yet */ }
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2467
|
+
if (baselineInFlight.has(sha)) return baselineInFlight.get(sha);
|
|
2468
|
+
const job = (async () => {
|
|
2469
|
+
const wt = join(repoRoot, '.manager-wt', `baseline-${sha.slice(0, 12)}`);
|
|
2470
|
+
let base = null;
|
|
2471
|
+
try {
|
|
2472
|
+
try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* nothing to clean */ }
|
|
2473
|
+
gitSync(repoRoot, ['worktree', 'add', '--detach', wt, sha]);
|
|
2474
|
+
base = await runProjectTests(wt);
|
|
2475
|
+
} catch {
|
|
2476
|
+
// Worktree couldn't be made (odd repo / offline no-remote) — fall back to
|
|
2477
|
+
// the registry dir in place = the pre-fix behaviour. Never worse.
|
|
2478
|
+
try { base = await runProjectTests(dir); } catch { base = null; }
|
|
2479
|
+
} finally {
|
|
2480
|
+
try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
|
|
2481
|
+
}
|
|
2482
|
+
if (!base?.ran) return [];
|
|
2483
|
+
const failingNames = base.failingNames ?? [];
|
|
2484
|
+
try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
|
|
2485
|
+
return failingNames;
|
|
2486
|
+
})();
|
|
2487
|
+
baselineInFlight.set(sha, job);
|
|
2488
|
+
try { return await job; } finally { baselineInFlight.delete(sha); }
|
|
2393
2489
|
}
|
|
2394
2490
|
|
|
2395
2491
|
// ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
|
|
@@ -2508,21 +2604,19 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
2508
2604
|
const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
|
|
2509
2605
|
const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
|
|
2510
2606
|
const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
|
|
2511
|
-
|
|
2607
|
+
// fallback = current behavior: title/changed derived from the goal's own text,
|
|
2608
|
+
// so a goal with no summary (AI failed or nothing to summarize) still shows a
|
|
2609
|
+
// meaningful title/changed and never a blank or mis-phrased "…の修正".
|
|
2610
|
+
const fallback = {
|
|
2611
|
+
title: (goal.text ?? '').replace(/\s+/g, ' ').slice(0, 60),
|
|
2612
|
+
check: '',
|
|
2613
|
+
changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
|
|
2614
|
+
};
|
|
2512
2615
|
if (!changed.length && !reports) return fallback;
|
|
2513
|
-
const prompt =
|
|
2514
|
-
`あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で2行だけ書いてください。`,
|
|
2515
|
-
'専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
|
|
2516
|
-
'{"check":"何を確認すれば良いか(1行)","changed":"何を変えたか(1行)"}',
|
|
2517
|
-
'',
|
|
2518
|
-
`ゴール: ${(goal.text ?? '').slice(0, 300)}`,
|
|
2519
|
-
`変更ファイル: ${changed.join(', ') || '(なし)'}`,
|
|
2520
|
-
`ワーカー報告:\n${reports.slice(0, 1500)}`,
|
|
2521
|
-
].join('\n');
|
|
2616
|
+
const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
|
|
2522
2617
|
try {
|
|
2523
2618
|
const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
|
|
2524
|
-
|
|
2525
|
-
if (m) { const o = JSON.parse(m[0]); return { check: String(o.check ?? '').replace(/\s+/g, ' ').slice(0, 120), changed: String(o.changed ?? '').replace(/\s+/g, ' ').slice(0, 120) || fallback.changed }; }
|
|
2619
|
+
return parseReviewSummary(r.result, fallback);
|
|
2526
2620
|
} catch { /* fall through */ }
|
|
2527
2621
|
return fallback;
|
|
2528
2622
|
}
|
|
@@ -3081,8 +3175,8 @@ const server = createServer(async (req, res) => {
|
|
|
3081
3175
|
email: licenseState.email,
|
|
3082
3176
|
isPaying: Boolean(licenseState.isPaying),
|
|
3083
3177
|
plan: licenseState.isPaying ? 'paid' : 'free',
|
|
3084
|
-
usage: {
|
|
3085
|
-
limits: {
|
|
3178
|
+
usage: { cumulative: freeUsage.cumulativeCount },
|
|
3179
|
+
limits: { cumulative: FREE_TIER_LIMITS.maxCumulativeGoals },
|
|
3086
3180
|
price: await getBillingPrice(),
|
|
3087
3181
|
siteUrl: BILLING_API_URL || null,
|
|
3088
3182
|
};
|
|
@@ -3272,6 +3366,18 @@ const server = createServer(async (req, res) => {
|
|
|
3272
3366
|
return;
|
|
3273
3367
|
}
|
|
3274
3368
|
|
|
3369
|
+
// Per-project play/pause (Masa: Doing列の一時停止/再生・チャットからも同じ). POST
|
|
3370
|
+
// .../pause stops in-flight Doing work + holds the queue; .../resume continues
|
|
3371
|
+
// from where it left off. Broadcasts the projects change so every client's
|
|
3372
|
+
// toggle updates. Idempotent: pausing an already-paused project is a 200 no-op.
|
|
3373
|
+
const pauseMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/(pause|resume)$/);
|
|
3374
|
+
if (pauseMatch && req.method === 'POST') {
|
|
3375
|
+
const project = projects.find((p) => p.id === pauseMatch[1]);
|
|
3376
|
+
if (!project) return json(res, 404, { error: 'project not found' });
|
|
3377
|
+
if (pauseMatch[2] === 'pause') pauseProject(project.id); else resumeProject(project.id);
|
|
3378
|
+
return json(res, 200, { project, projects, paused: !!project.paused });
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3275
3381
|
// proof artifacts: /proof/<taskId>/<file> (gif/png/mp4 from that task's proof dir)
|
|
3276
3382
|
const proofMatch = url.pathname.match(/^\/proof\/(\d+)\/([\w.-]+)$/);
|
|
3277
3383
|
if (proofMatch) {
|
|
@@ -3301,6 +3407,21 @@ const server = createServer(async (req, res) => {
|
|
|
3301
3407
|
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
|
|
3302
3408
|
const project = projects.find((p) => p.id === projectId);
|
|
3303
3409
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
3410
|
+
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
|
|
3411
|
+
// (no LLM), checked BEFORE the intent router below: a short command-like
|
|
3412
|
+
// "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
|
|
3413
|
+
// — it never becomes a To-Do. /later and /review skip this like the router.
|
|
3414
|
+
if (!pending && !review) {
|
|
3415
|
+
const pauseIntent = detectPauseIntent(text.trim());
|
|
3416
|
+
if (pauseIntent === 'pause') {
|
|
3417
|
+
pauseProject(projectId);
|
|
3418
|
+
return json(res, 200, { kind: 'chat', reply: `${project.name} を一時停止しました。動いていた作業を止め、新しいToDoも始めません。再生(▶)で続きから再開します。` });
|
|
3419
|
+
}
|
|
3420
|
+
if (pauseIntent === 'resume') {
|
|
3421
|
+
resumeProject(projectId);
|
|
3422
|
+
return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3304
3425
|
// Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
|
|
3305
3426
|
// composer is conversation-first. A chat/help/settings message ("what can
|
|
3306
3427
|
// you do?", "reply in English", a greeting) gets a conversational reply
|
|
@@ -3663,6 +3784,9 @@ const server = createServer(async (req, res) => {
|
|
|
3663
3784
|
if (!requireGoalOwnership(req, res, goal)) return;
|
|
3664
3785
|
const result = advanceReviewGoal(goal);
|
|
3665
3786
|
if (!result.ok) return json(res, 409, { error: result.error });
|
|
3787
|
+
// 07-20c: PR ありは done にせず「PR をマージして完了」を促す。status は review の
|
|
3788
|
+
// まま。マージ後に syncGoalMerges が done を確定する。
|
|
3789
|
+
if (result.needsMerge) return json(res, 200, { goal, needsMerge: true, pr: goal.pr });
|
|
3666
3790
|
goal.status = result.status;
|
|
3667
3791
|
saveGoal(goal);
|
|
3668
3792
|
return json(res, 200, goal);
|
|
@@ -3924,8 +4048,12 @@ const server = createServer(async (req, res) => {
|
|
|
3924
4048
|
pump(goal.projectId);
|
|
3925
4049
|
return json(res, 200, goal);
|
|
3926
4050
|
}
|
|
3927
|
-
const result = approveGoal({ goalStatus: goal.status });
|
|
4051
|
+
const result = approveGoal({ goalStatus: goal.status, hasPr: !!goal.pr });
|
|
3928
4052
|
if (!result.ok) return json(res, 409, { error: result.error });
|
|
4053
|
+
// 07-20c: PR ありは Approve だけで done にしない。status は review のまま返し、
|
|
4054
|
+
// クライアントは「PR をマージして完了してください」ダイアログ+ Open PR を出す。
|
|
4055
|
+
// マージされたら syncGoalMerges が review→done を自動確定する。
|
|
4056
|
+
if (result.needsMerge) return json(res, 200, { goal, needsMerge: true, pr: goal.pr });
|
|
3929
4057
|
goal.status = result.status;
|
|
3930
4058
|
saveGoal(goal);
|
|
3931
4059
|
return json(res, 200, goal);
|
|
@@ -4105,6 +4233,46 @@ const server = createServer(async (req, res) => {
|
|
|
4105
4233
|
return;
|
|
4106
4234
|
}
|
|
4107
4235
|
|
|
4236
|
+
// 07-20c: Reject — park a review goal in the Rejected column. No auto-rework
|
|
4237
|
+
// (rework is asked for via chat /reply or /dismiss). Kept as a SEPARATE
|
|
4238
|
+
// endpoint from /dismiss so the existing chat-rework contract is untouched.
|
|
4239
|
+
// An optional text is kept as a rejection note; no worker is spawned.
|
|
4240
|
+
const rejectMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reject$/);
|
|
4241
|
+
if (rejectMatch && req.method === 'POST') {
|
|
4242
|
+
const goal = goals.find((g) => g.id === Number(rejectMatch[1]));
|
|
4243
|
+
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
4244
|
+
if (!requireGoalOwnership(req, res, goal)) return;
|
|
4245
|
+
const result = rejectGoal({ goalStatus: goal.status });
|
|
4246
|
+
if (!result.ok) return json(res, 409, { error: result.error });
|
|
4247
|
+
let body = '';
|
|
4248
|
+
req.on('data', (d) => { body += d; });
|
|
4249
|
+
req.on('end', () => {
|
|
4250
|
+
let text = '';
|
|
4251
|
+
try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
|
|
4252
|
+
goal.status = result.status;
|
|
4253
|
+
if (text) goal.rejectNote = text;
|
|
4254
|
+
goal.rejectedAt = new Date().toISOString();
|
|
4255
|
+
saveGoal(goal);
|
|
4256
|
+
json(res, 200, { goal });
|
|
4257
|
+
});
|
|
4258
|
+
return;
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
// 07-20c: Rejected 欄の『Reopen』——rejected を review へ戻す(Reject の取り消し)。
|
|
4262
|
+
// Delete(破棄)は既存の /revert(PR を閉じ worktree を落とす)を使う。
|
|
4263
|
+
const reopenMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reopen$/);
|
|
4264
|
+
if (reopenMatch && req.method === 'POST') {
|
|
4265
|
+
const goal = goals.find((g) => g.id === Number(reopenMatch[1]));
|
|
4266
|
+
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
4267
|
+
if (!requireGoalOwnership(req, res, goal)) return;
|
|
4268
|
+
const result = reopenGoal({ goalStatus: goal.status });
|
|
4269
|
+
if (!result.ok) return json(res, 409, { error: result.error });
|
|
4270
|
+
goal.status = result.status;
|
|
4271
|
+
goal.rejectNote = null; goal.rejectedAt = null;
|
|
4272
|
+
saveGoal(goal);
|
|
4273
|
+
return json(res, 200, goal);
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4108
4276
|
// image attachments: upload (base64 JSON) + serve back for thumbnails
|
|
4109
4277
|
if (url.pathname === '/api/upload' && req.method === 'POST') {
|
|
4110
4278
|
let body = '';
|
package/package.json
CHANGED