@galda/cli 0.10.16 → 0.10.19
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 +30 -1
- package/app/index.html +418 -53
- package/bin/manager-for-ai.mjs +18 -12
- package/engine/lib.mjs +536 -12
- package/engine/relay-client.mjs +101 -32
- package/engine/server.mjs +478 -20
- package/package.json +2 -2
package/engine/server.mjs
CHANGED
|
@@ -13,15 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import { spawn, execFile, spawnSync } from 'node:child_process';
|
|
16
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
17
17
|
import { emitEvent } from './analytics-client.mjs';
|
|
18
|
-
import { randomBytes, createHash } from 'node:crypto';
|
|
18
|
+
import { randomBytes, createHash, randomUUID } from 'node:crypto';
|
|
19
19
|
import { resolve, dirname, join, basename } from 'node:path';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
|
-
import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed } 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, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable } from './lib.mjs';
|
|
23
|
+
import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } 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, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
|
|
25
25
|
import { openPR } from './pr.mjs';
|
|
26
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
27
27
|
|
|
@@ -471,7 +471,7 @@ if (!existsSync(projectsFile)) {
|
|
|
471
471
|
writeFileSync(projectsFile, JSON.stringify([{ id: 'default', name: basename(cwd), dir: cwd }], null, 2));
|
|
472
472
|
}
|
|
473
473
|
const projects = JSON.parse(readFileSync(projectsFile, 'utf8'));
|
|
474
|
-
function saveProjects() { writeFileSync(projectsFile, JSON.stringify(projects, null, 2)); }
|
|
474
|
+
function saveProjects() { writeFileSync(projectsFile, JSON.stringify(projects, null, 2)); markSyncDirty(); }
|
|
475
475
|
function uniqueProjectId(name = 'project') {
|
|
476
476
|
const base = String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 28) || 'project';
|
|
477
477
|
let id = base, n = 2;
|
|
@@ -487,16 +487,49 @@ const logFile = join(logDir, 'tasks.jsonl');
|
|
|
487
487
|
// four buckets goalGroupStatus/goalChip actually track (see lib.mjs).
|
|
488
488
|
const workflowColumnsFile = join(DATA_DIR, 'workflow-columns.json');
|
|
489
489
|
let workflowColumns = existsSync(workflowColumnsFile) ? JSON.parse(readFileSync(workflowColumnsFile, 'utf8')) : {};
|
|
490
|
-
function saveWorkflowColumns() { writeFileSync(workflowColumnsFile, JSON.stringify(workflowColumns, null, 2)); }
|
|
490
|
+
function saveWorkflowColumns() { writeFileSync(workflowColumnsFile, JSON.stringify(workflowColumns, null, 2)); markSyncDirty(); }
|
|
491
491
|
function getColumns(projectId) { return workflowColumns[projectId] ?? DEFAULT_WORKFLOW_COLUMNS; }
|
|
492
492
|
|
|
493
493
|
// Per-project review definition (task 45): configurable conditions for when
|
|
494
494
|
// a finished goal lands in 'review' vs 'done'/'partial' — see lib.mjs.
|
|
495
495
|
const reviewDefinitionsFile = join(DATA_DIR, 'review-definitions.json');
|
|
496
496
|
let reviewDefinitions = existsSync(reviewDefinitionsFile) ? JSON.parse(readFileSync(reviewDefinitionsFile, 'utf8')) : {};
|
|
497
|
-
function saveReviewDefinitions() { writeFileSync(reviewDefinitionsFile, JSON.stringify(reviewDefinitions, null, 2)); }
|
|
497
|
+
function saveReviewDefinitions() { writeFileSync(reviewDefinitionsFile, JSON.stringify(reviewDefinitions, null, 2)); markSyncDirty(); }
|
|
498
498
|
function getReviewDefinition(projectId) { return reviewDefinitions[projectId] ?? DEFAULT_REVIEW_DEFINITION; }
|
|
499
499
|
|
|
500
|
+
// ---- cross-device board sync state (P3, docs/design/CROSS-DEVICE-SYNC-PLAN.md)
|
|
501
|
+
// rev = the optimistic-lock version this device last saw/wrote; writerId = a
|
|
502
|
+
// stable per-install id so the store can tell which device last wrote. Kept in
|
|
503
|
+
// its own tiny file next to the other state so it survives restarts. The push/
|
|
504
|
+
// pull machinery itself is defined further down (function declarations hoist);
|
|
505
|
+
// this only sets up the persisted rev/writerId it needs.
|
|
506
|
+
const SYNC_PUSH_DEBOUNCE_MS = 2500;
|
|
507
|
+
const SYNC_HTTP_TIMEOUT_MS = 5000; // defined here (not in the push/pull block below) so the boot-time `await bootSyncPull()` can read it — a `const` there would be in its TDZ at boot
|
|
508
|
+
const syncMetaFile = join(DATA_DIR, 'sync-meta.json');
|
|
509
|
+
let syncMeta = { rev: 0, writerId: null };
|
|
510
|
+
try { if (existsSync(syncMetaFile)) syncMeta = { ...syncMeta, ...JSON.parse(readFileSync(syncMetaFile, 'utf8')) }; }
|
|
511
|
+
catch { /* corrupt meta → start clean (a fresh pull re-establishes rev) */ }
|
|
512
|
+
if (!syncMeta.writerId) syncMeta.writerId = randomUUID();
|
|
513
|
+
function saveSyncMeta() { try { writeFileSync(syncMetaFile, JSON.stringify(syncMeta)); } catch { /* best-effort */ } }
|
|
514
|
+
saveSyncMeta();
|
|
515
|
+
let _syncDisabled = false; // set when the store says 501 (no bucket provisioned) — stop hammering
|
|
516
|
+
let _syncHydrating = false; // true while applying a pulled board, so our own saves don't re-arm a push
|
|
517
|
+
let _syncPushTimer = null;
|
|
518
|
+
let _syncPushInFlight = false;
|
|
519
|
+
let _syncDirtyAgain = false; // a save landed mid-push → coalesce into one follow-up push
|
|
520
|
+
|
|
521
|
+
// Which worker agent CLIs are installed on this machine — the launcher
|
|
522
|
+
// (bin/manager-for-ai.mjs) detects them (codex via a PATH lookup, never executed)
|
|
523
|
+
// and passes the set here. Galda offers only these, so a Codex-only machine gets
|
|
524
|
+
// a working board and a claude-only one never shows Codex. Defaults to Claude
|
|
525
|
+
// Code when unset (tests / running the server directly).
|
|
526
|
+
const envAgents = (process.env.MANAGER_AVAILABLE_AGENTS || 'claude-code')
|
|
527
|
+
.split(',').map((a) => a.trim()).filter(Boolean);
|
|
528
|
+
const AVAILABLE_AGENTS = resolveConnectedAgents({
|
|
529
|
+
claude: envAgents.includes('claude-code'),
|
|
530
|
+
codex: envAgents.includes('codex'),
|
|
531
|
+
});
|
|
532
|
+
|
|
500
533
|
// ---- state ---------------------------------------------------------------
|
|
501
534
|
// Restarts must not lose pending work: tasks that never started stay
|
|
502
535
|
// queued; only mid-run work becomes 'interrupted' (retryable). Goals that
|
|
@@ -508,6 +541,14 @@ let tasks = replayed.tasks;
|
|
|
508
541
|
let nextId = replayed.nextId;
|
|
509
542
|
let prioCounter = replayed.prioCounter;
|
|
510
543
|
|
|
544
|
+
// Cross-device sync (P3): before anything downstream (orphan reconcile, the
|
|
545
|
+
// queues Map, workers) is built from this state, adopt a newer board from the
|
|
546
|
+
// account if one exists — this is where a second device inherits the work on
|
|
547
|
+
// boot. No-op unless billing + a license token are configured (local / dev /
|
|
548
|
+
// free installs stay fully local), and time-boxed so a dead network can't hang
|
|
549
|
+
// startup. If nothing to pull but we have local work, seed the store instead.
|
|
550
|
+
await bootSyncPull();
|
|
551
|
+
|
|
511
552
|
// Reap goals a dead server left with no live worker (Masa dogfood: the To Do
|
|
512
553
|
// pile-up from a goal that never actually resumes) — see reconcileOrphanGoals
|
|
513
554
|
// in lib.mjs for the exact rule (unit-tested there). Computed and applied to
|
|
@@ -582,7 +623,19 @@ function sendGoalEvent(obj, goal) {
|
|
|
582
623
|
if (goalVisibleTo(goal, identity, MANAGER_OWNER_EMAIL)) res.write(data);
|
|
583
624
|
}
|
|
584
625
|
}
|
|
585
|
-
|
|
626
|
+
// Durable append to the restart-safe log. A failing disk (ENOSPC when the
|
|
627
|
+
// volume is full, EACCES/EROFS) must NEVER freeze the board: without this guard
|
|
628
|
+
// an appendFileSync throw inside a handler leaves the HTTP request hanging (no
|
|
629
|
+
// response) AND skips the SSE broadcast that follows, so a delete/answer looks
|
|
630
|
+
// like it did nothing — the question card stays stuck forever (Masa 2026-07-16).
|
|
631
|
+
// We swallow the write error (logged), and let the in-memory mutation + SSE
|
|
632
|
+
// proceed regardless. Durability is best-effort here; a live UI wins over a
|
|
633
|
+
// perfectly-replayable log on a disk that can't be written to anyway.
|
|
634
|
+
function logAppend(line) {
|
|
635
|
+
try { appendFileSync(logFile, line + '\n'); markSyncDirty(); return true; }
|
|
636
|
+
catch (e) { console.error(`[manager] log write failed (${e?.code || e}) — state not persisted this write, UI still updated`); return false; }
|
|
637
|
+
}
|
|
638
|
+
function saveGoal(g) { logAppend(JSON.stringify({ ...g, kind: 'goal' })); sendGoalEvent({ ev: 'goal', goal: g }, g); }
|
|
586
639
|
// Persist the orphan-goal reclassification computed right after replayQueueLog
|
|
587
640
|
// above (deferred to here because saveGoal/send/sseClients don't exist yet at
|
|
588
641
|
// that point in the module) — writes it to the restart-safe log so it settles
|
|
@@ -594,7 +647,7 @@ for (const fix of orphanFixes) {
|
|
|
594
647
|
}
|
|
595
648
|
function saveTask(t) {
|
|
596
649
|
const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
|
|
597
|
-
|
|
650
|
+
logAppend(JSON.stringify({ ...persist, kind: 'task' }));
|
|
598
651
|
const goal = goals.find((g) => g.id === t.goalId);
|
|
599
652
|
if (goal) sendGoalEvent({ ev: 'task', task: persist }, goal); else send({ ev: 'task', task: persist });
|
|
600
653
|
emitTaskAnalytics(t);
|
|
@@ -648,6 +701,202 @@ function postHistory(t) {
|
|
|
648
701
|
}).catch(() => {});
|
|
649
702
|
} catch { /* never throw into the caller */ }
|
|
650
703
|
}
|
|
704
|
+
// ---- cross-device board sync: push / pull (P3) ----------------------------
|
|
705
|
+
// docs/design/CROSS-DEVICE-SYNC-PLAN.md. This process owns the board (it holds
|
|
706
|
+
// the state + the license token); it pushes a debounced snapshot to the sync
|
|
707
|
+
// Worker (P2) and pulls on boot so a second device inherits the work. Opt-in:
|
|
708
|
+
// disabled unless a billing URL AND a license token are present, and it degrades
|
|
709
|
+
// silently (501 = bucket not provisioned) so local/dev/free installs are never
|
|
710
|
+
// affected. Pure decisions live in lib.mjs (buildBoardSnapshot / decideBoardPull).
|
|
711
|
+
// SYNC_HTTP_TIMEOUT_MS is declared up with the sync state (boot reads it early).
|
|
712
|
+
function syncEnabled() { return Boolean(BILLING_API_URL) && !_syncDisabled && Boolean(readLicenseToken()); }
|
|
713
|
+
function currentBoard() {
|
|
714
|
+
return buildBoardSnapshot({
|
|
715
|
+
projects,
|
|
716
|
+
tasksLog: existsSync(logFile) ? readFileSync(logFile, 'utf8') : '',
|
|
717
|
+
workflowColumns,
|
|
718
|
+
reviewDefinitions,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function localHasWork() { return goals.length > 0; }
|
|
722
|
+
// Overwrite in-memory + on-disk state from a validated remote board. Only ever
|
|
723
|
+
// called at boot (before queues/workers exist), so it can safely reassign the
|
|
724
|
+
// module-level state vars and rewrite the append-only log wholesale — a pull is
|
|
725
|
+
// a full replacement, not a merge. nextId/prioCounter come from the replay so
|
|
726
|
+
// ids can't collide with the adopted goals/tasks.
|
|
727
|
+
function hydrateFromBoard(snap) {
|
|
728
|
+
_syncHydrating = true;
|
|
729
|
+
try {
|
|
730
|
+
projects.length = 0; // `const` array → mutate in place
|
|
731
|
+
for (const p of snap.projects) projects.push(p);
|
|
732
|
+
saveProjects();
|
|
733
|
+
workflowColumns = snap.workflowColumns; saveWorkflowColumns();
|
|
734
|
+
reviewDefinitions = snap.reviewDefinitions; saveReviewDefinitions();
|
|
735
|
+
try { writeFileSync(logFile, snap.tasksLog); }
|
|
736
|
+
catch (e) { console.log(`[sync] hydrate: log write failed (${e?.code || e})`); }
|
|
737
|
+
const r = replayQueueLog(snap.tasksLog || '', reviewDefinitions);
|
|
738
|
+
goals = r.goals; tasks = r.tasks; nextId = r.nextId; prioCounter = r.prioCounter;
|
|
739
|
+
// A pulled 'running' goal has no live worker on this device — reconcile it
|
|
740
|
+
// exactly as a restart would (same rule replayQueueLog callers use at boot).
|
|
741
|
+
for (const fix of reconcileOrphanGoals(goals, tasks)) {
|
|
742
|
+
const g = goals.find((x) => x.id === fix.id);
|
|
743
|
+
if (g) { g.status = fix.status; g.blocked = null; g.prError = fix.reason; }
|
|
744
|
+
}
|
|
745
|
+
} finally { _syncHydrating = false; }
|
|
746
|
+
}
|
|
747
|
+
// Schedule a debounced push. Coalesces bursts of saves into one PUT, and never
|
|
748
|
+
// re-arms while we're applying a pulled board (that would ping-pong).
|
|
749
|
+
function markSyncDirty() {
|
|
750
|
+
if (_syncHydrating || !syncEnabled()) return;
|
|
751
|
+
if (_syncPushTimer) return;
|
|
752
|
+
_syncPushTimer = setTimeout(() => { _syncPushTimer = null; pushBoard(); }, SYNC_PUSH_DEBOUNCE_MS);
|
|
753
|
+
if (_syncPushTimer.unref) _syncPushTimer.unref(); // never keep the process alive for a pending push
|
|
754
|
+
}
|
|
755
|
+
async function pushBoard() {
|
|
756
|
+
if (!syncEnabled()) return;
|
|
757
|
+
if (_syncPushInFlight) { _syncDirtyAgain = true; return; }
|
|
758
|
+
_syncPushInFlight = true;
|
|
759
|
+
try {
|
|
760
|
+
const token = readLicenseToken();
|
|
761
|
+
const res = await fetch(`${BILLING_API_URL}/sync/board`, {
|
|
762
|
+
method: 'PUT',
|
|
763
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
764
|
+
body: JSON.stringify({ baseRev: syncMeta.rev, board: currentBoard(), writerId: syncMeta.writerId }),
|
|
765
|
+
signal: AbortSignal.timeout(SYNC_HTTP_TIMEOUT_MS),
|
|
766
|
+
});
|
|
767
|
+
if (res.status === 501) { _syncDisabled = true; console.log('[sync] board store not provisioned (501) — cross-device sync disabled this session'); return; }
|
|
768
|
+
if (res.status === 409) {
|
|
769
|
+
// Another device advanced the board first. Cross-device is takeover, not
|
|
770
|
+
// concurrent editing (a non-goal): adopt the remote rev as our new base
|
|
771
|
+
// and re-push our board on top (last-writer-wins), so we converge in one
|
|
772
|
+
// extra round instead of hydrating mid-run.
|
|
773
|
+
const j = await res.json().catch(() => ({}));
|
|
774
|
+
if (typeof j.rev === 'number') { syncMeta.rev = j.rev; saveSyncMeta(); }
|
|
775
|
+
console.log(`[sync] push stale — rebased onto remote rev ${j.rev ?? '?'} and re-pushing`);
|
|
776
|
+
_syncDirtyAgain = true;
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (!res.ok) { console.log(`[sync] push failed (HTTP ${res.status})`); return; }
|
|
780
|
+
const j = await res.json().catch(() => ({}));
|
|
781
|
+
if (typeof j.rev === 'number') { syncMeta.rev = j.rev; saveSyncMeta(); }
|
|
782
|
+
} catch (e) { console.log(`[sync] push error: ${e?.message || e}`); }
|
|
783
|
+
finally {
|
|
784
|
+
_syncPushInFlight = false;
|
|
785
|
+
if (_syncDirtyAgain) { _syncDirtyAgain = false; markSyncDirty(); }
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Boot-time pull. Adopts a newer remote board (device inherits the work); if
|
|
789
|
+
// nothing to adopt but this device has local work, seeds the store so the FIRST
|
|
790
|
+
// device populates it. Time-boxed; any failure leaves local state untouched.
|
|
791
|
+
async function bootSyncPull() {
|
|
792
|
+
if (!BILLING_API_URL || _syncDisabled) return;
|
|
793
|
+
const token = readLicenseToken();
|
|
794
|
+
if (!token) return;
|
|
795
|
+
let res;
|
|
796
|
+
try {
|
|
797
|
+
res = await fetch(`${BILLING_API_URL}/sync/board`, {
|
|
798
|
+
headers: { authorization: `Bearer ${token}` },
|
|
799
|
+
signal: AbortSignal.timeout(SYNC_HTTP_TIMEOUT_MS),
|
|
800
|
+
});
|
|
801
|
+
} catch (e) { console.log(`[sync] boot pull skipped (${e?.name === 'TimeoutError' ? 'timeout' : e?.message || e}) — starting from local state`); return; }
|
|
802
|
+
if (res.status === 501) { _syncDisabled = true; console.log('[sync] board store not provisioned (501) — cross-device sync disabled this session'); return; }
|
|
803
|
+
if (!res.ok) { console.log(`[sync] boot pull failed (HTTP ${res.status}) — starting from local state`); return; }
|
|
804
|
+
const remote = await res.json().catch(() => ({}));
|
|
805
|
+
const remoteRev = Number(remote?.rev) || 0;
|
|
806
|
+
const remoteSnap = remote?.board ? validateBoardSnapshot(remote.board) : null;
|
|
807
|
+
const decision = decideBoardPull({
|
|
808
|
+
localRev: syncMeta.rev,
|
|
809
|
+
remoteRev,
|
|
810
|
+
localHasWork: localHasWork(),
|
|
811
|
+
remoteHasWork: remoteSnap ? !boardIsEmpty(remoteSnap) : false,
|
|
812
|
+
});
|
|
813
|
+
if (decision.pull) {
|
|
814
|
+
if (!remoteSnap) { console.log('[sync] boot pull: remote board invalid/unsupported version — ignored, using local state'); return; }
|
|
815
|
+
if (decision.clobbersLocal) console.log('[sync] WARNING: adopting the account board over un-synced local work (last-writer-wins; concurrent offline editing is not supported)');
|
|
816
|
+
hydrateFromBoard(remoteSnap);
|
|
817
|
+
syncMeta.rev = remoteRev; saveSyncMeta();
|
|
818
|
+
console.log(`[sync] board inherited from account (rev ${remoteRev})`);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
// Nothing newer to adopt. If we're the first device with real work, seed it.
|
|
822
|
+
if (localHasWork() && syncEnabled()) { console.log('[sync] seeding account board from local state'); markSyncDirty(); }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// True while this device is running any work of its own — a planning/running
|
|
826
|
+
// goal, or a live worker child. hydrateFromBoard rewrites the log wholesale and
|
|
827
|
+
// swaps goals/tasks, which would orphan an in-flight worker's writes, so a live
|
|
828
|
+
// takeover must refuse while busy (a boot pull never has this problem: nothing
|
|
829
|
+
// is running yet). `goalWorkerChildren` is declared below but only read here at
|
|
830
|
+
// request time, long after module init, so the forward reference is fine.
|
|
831
|
+
function deviceBusy() {
|
|
832
|
+
return goalWorkerChildren.size > 0 || goals.some((g) => ['planning', 'running'].includes(g.status));
|
|
833
|
+
}
|
|
834
|
+
// Live (mid-session) takeover pull (P4): adopt the account's newer board into
|
|
835
|
+
// this already-serving instance, on demand (the switch-UI's 切替 button). Boot
|
|
836
|
+
// pull (bootSyncPull) inherits on startup; this is the same adoption triggered
|
|
837
|
+
// while running. Refuses when this device is busy (deviceBusy) so it can never
|
|
838
|
+
// yank the board out from under a live worker. On a successful 'pull' this
|
|
839
|
+
// device now holds the account board at its rev; the caller reloads to render
|
|
840
|
+
// it. Returns { ok, action, rev } — see liveTakeoverDecision for the actions.
|
|
841
|
+
async function livePull() {
|
|
842
|
+
if (!syncEnabled()) return { ok: false, action: 'disabled' };
|
|
843
|
+
if (deviceBusy()) return { ok: false, action: 'busy' };
|
|
844
|
+
const token = readLicenseToken();
|
|
845
|
+
let res;
|
|
846
|
+
try {
|
|
847
|
+
res = await fetch(`${BILLING_API_URL}/sync/board`, {
|
|
848
|
+
headers: { authorization: `Bearer ${token}` },
|
|
849
|
+
signal: AbortSignal.timeout(SYNC_HTTP_TIMEOUT_MS),
|
|
850
|
+
});
|
|
851
|
+
} catch (e) {
|
|
852
|
+
console.log(`[sync] live pull skipped (${e?.name === 'TimeoutError' ? 'timeout' : e?.message || e})`);
|
|
853
|
+
return { ok: false, action: 'error' };
|
|
854
|
+
}
|
|
855
|
+
if (res.status === 501) { _syncDisabled = true; return { ok: false, action: 'disabled' }; }
|
|
856
|
+
if (!res.ok) { console.log(`[sync] live pull failed (HTTP ${res.status})`); return { ok: false, action: 'error' }; }
|
|
857
|
+
const remote = await res.json().catch(() => ({}));
|
|
858
|
+
const remoteRev = Number(remote?.rev) || 0;
|
|
859
|
+
const remoteSnap = remote?.board ? validateBoardSnapshot(remote.board) : null;
|
|
860
|
+
const decision = liveTakeoverDecision({
|
|
861
|
+
syncEnabled: true,
|
|
862
|
+
busy: false,
|
|
863
|
+
remoteValid: Boolean(remoteSnap),
|
|
864
|
+
localRev: syncMeta.rev,
|
|
865
|
+
remoteRev,
|
|
866
|
+
localHasWork: localHasWork(),
|
|
867
|
+
remoteHasWork: remoteSnap ? !boardIsEmpty(remoteSnap) : false,
|
|
868
|
+
});
|
|
869
|
+
if (decision.action !== 'pull') return { ok: decision.action === 'current', action: decision.action, rev: syncMeta.rev };
|
|
870
|
+
if (decision.clobbersLocal) console.log('[sync] live takeover adopting account board over un-synced local work (last-writer-wins)');
|
|
871
|
+
hydrateFromBoard(remoteSnap);
|
|
872
|
+
syncMeta.rev = remoteRev; saveSyncMeta();
|
|
873
|
+
rebuildQueuesAfterAdopt();
|
|
874
|
+
console.log(`[sync] live takeover: board adopted from account (rev ${remoteRev})`);
|
|
875
|
+
return { ok: true, action: 'pull', rev: remoteRev };
|
|
876
|
+
}
|
|
877
|
+
// hydrateFromBoard replaces projects/goals/tasks, but the `queues` Map still
|
|
878
|
+
// mirrors the OLD board: adopted projects have no entry (so /api/state throws
|
|
879
|
+
// on queues.get(p.id).waiting), and adopted 'queued' tasks sit in no waiting
|
|
880
|
+
// list. A boot pull never hits this — the Map is built from projects *after*
|
|
881
|
+
// hydrate — but a live pull must rebuild it here. The pull was gated on
|
|
882
|
+
// deviceBusy() being false, so nothing is running and the Map can be rebuilt
|
|
883
|
+
// wholesale. This mirrors the boot re-queue/plan loop (server start, below):
|
|
884
|
+
// re-queue pending tasks, pump, and plan any 'stacked' goal so the inherited
|
|
885
|
+
// work actually resumes on this device — the whole point of a takeover.
|
|
886
|
+
function rebuildQueuesAfterAdopt() {
|
|
887
|
+
queues.clear();
|
|
888
|
+
for (const p of projects) queues.set(p.id, { running: new Set(), waiting: [] });
|
|
889
|
+
for (const p of projects) {
|
|
890
|
+
const pending = tasks.filter((t) => t.projectId === p.id && t.status === 'queued').sort((a, b) => a.num - b.num);
|
|
891
|
+
queues.get(p.id).waiting.push(...pending);
|
|
892
|
+
pump(p.id);
|
|
893
|
+
for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
|
|
894
|
+
g.status = 'planning'; saveGoal(g);
|
|
895
|
+
planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
651
900
|
const ACTIVITY_LIMIT = 1000;
|
|
652
901
|
function activityLine(line) {
|
|
653
902
|
return String(line ?? '')
|
|
@@ -765,6 +1014,13 @@ function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model,
|
|
|
765
1014
|
});
|
|
766
1015
|
});
|
|
767
1016
|
}
|
|
1017
|
+
// Small internal LLM calls (intent classification, proof summaries, board Q&A).
|
|
1018
|
+
// Prefer Claude Code + haiku, but fall back to whatever agent is installed so a
|
|
1019
|
+
// Codex-only machine still works — never a hardcoded `claude` that isn't there.
|
|
1020
|
+
function runUtility(args) {
|
|
1021
|
+
const agent = pickUtilityAgent(AVAILABLE_AGENTS);
|
|
1022
|
+
return runWorkerAgent({ agent, model: utilityModel(agent), ...args });
|
|
1023
|
+
}
|
|
768
1024
|
function runWorkerAgent({ agent, ...args }) {
|
|
769
1025
|
return workerAgent(agent) === 'codex' ? runCodex(args) : runClaude(args);
|
|
770
1026
|
}
|
|
@@ -986,6 +1242,62 @@ function startNextStacked(projectId) {
|
|
|
986
1242
|
});
|
|
987
1243
|
}
|
|
988
1244
|
|
|
1245
|
+
// Worker auth preflight state. The manager runs every worker by spawning a
|
|
1246
|
+
// standalone `claude` (runClaude), inheriting the launching shell's login. If
|
|
1247
|
+
// that shell has no usable credentials, every task fails late with "Not logged
|
|
1248
|
+
// in". We probe once at boot and hold the queue + raise a banner instead of
|
|
1249
|
+
// failing task after task. Optimistic default (ok:true) so we never falsely
|
|
1250
|
+
// block a correctly-authed setup during the ~1s probe.
|
|
1251
|
+
let workerAuth = { ok: true, reason: null, checkedAt: null };
|
|
1252
|
+
const publicAuth = () => ({ ok: workerAuth.ok, reason: workerAuth.reason });
|
|
1253
|
+
|
|
1254
|
+
// Boot preflight: one cheap Read-only `claude` call; classify the result (pure,
|
|
1255
|
+
// in lib) and broadcast so the UI can show/clear the banner. `claude -p` exits 0
|
|
1256
|
+
// even when logged out, so classifyAuthProbe reads the text, not the code.
|
|
1257
|
+
async function probeWorkerAuth() {
|
|
1258
|
+
try {
|
|
1259
|
+
const out = await runClaude({ prompt: 'Reply with the single word: ok', cwd: ROOT, tools: 'Read', model: 'haiku' });
|
|
1260
|
+
workerAuth = { ...classifyAuthProbe({ result: out?.result, code: out?.code }), checkedAt: Date.now() };
|
|
1261
|
+
} catch {
|
|
1262
|
+
workerAuth = { ok: false, reason: 'probe-failed', checkedAt: Date.now() };
|
|
1263
|
+
}
|
|
1264
|
+
console.log(workerAuth.ok
|
|
1265
|
+
? '[manager] worker auth preflight OK'
|
|
1266
|
+
: `[manager] worker auth preflight FAILED (${workerAuth.reason}) — holding the queue. ${authBannerText(workerAuth.reason, 'en')}`);
|
|
1267
|
+
send({ ev: 'auth', auth: publicAuth() });
|
|
1268
|
+
return workerAuth;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Layer 2 of the intent router: a general planner clarify card has no builder to
|
|
1272
|
+
// re-render from, so when the user asks to switch language mid-question we ask
|
|
1273
|
+
// the model (cheap, Read-only, no repo edits) to RESTATE the pending question in
|
|
1274
|
+
// the target language — same intent, same options, just translated. Keeps the
|
|
1275
|
+
// goal in needsInput (the user still hasn't answered). On any failure we keep the
|
|
1276
|
+
// original card so the user is never stranded. Pure parsing lives in
|
|
1277
|
+
// parseRelocalizedQuestion (unit-tested); this is the I/O shell.
|
|
1278
|
+
async function relocalizeGeneralQuestion(goal, lang, res) {
|
|
1279
|
+
const q0 = goal.question || {};
|
|
1280
|
+
const langName = lang === 'en' ? 'English' : 'Japanese (日本語)';
|
|
1281
|
+
const prompt = [
|
|
1282
|
+
`Restate the following clarifying question in ${langName}. Keep the meaning and each option; only translate. Do not answer it, do not add new options.`,
|
|
1283
|
+
`Question: ${q0.text ?? ''}`,
|
|
1284
|
+
Array.isArray(q0.options) && q0.options.length ? `Options: ${JSON.stringify(q0.options)}` : 'Options: []',
|
|
1285
|
+
'Return ONLY JSON: {"text": "...", "options": ["..."]}',
|
|
1286
|
+
].join('\n');
|
|
1287
|
+
try {
|
|
1288
|
+
const out = await runClaude({ prompt, cwd: ROOT, tools: 'Read', model: 'haiku' });
|
|
1289
|
+
const restated = parseRelocalizedQuestion(out?.result);
|
|
1290
|
+
goal.question = restated
|
|
1291
|
+
? { ...q0, text: restated.text, options: restated.options.length ? restated.options : (q0.options || []), lang }
|
|
1292
|
+
: { ...q0, lang };
|
|
1293
|
+
} catch {
|
|
1294
|
+
goal.question = { ...q0, lang };
|
|
1295
|
+
}
|
|
1296
|
+
goal.status = 'needsInput';
|
|
1297
|
+
saveGoal(goal);
|
|
1298
|
+
return json(res, 200, goal);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
989
1301
|
async function planGoal(goal) {
|
|
990
1302
|
const project = projects.find((p) => p.id === goal.projectId);
|
|
991
1303
|
const prompt = [
|
|
@@ -1102,6 +1414,10 @@ async function authorVerify(task, goal, project) {
|
|
|
1102
1414
|
// `lastStartAt` check in pump() below.
|
|
1103
1415
|
const slowModeLastStart = new Map(); // projectId -> ms epoch of the last task start
|
|
1104
1416
|
function pump(projectId) {
|
|
1417
|
+
// Worker auth preflight failed → hold the queue (the UI banner tells the user
|
|
1418
|
+
// to sign `claude` in and relaunch). Never spawn a worker that can only fail
|
|
1419
|
+
// with "Not logged in". Cleared automatically once a probe succeeds.
|
|
1420
|
+
if (!workerAuth.ok) return;
|
|
1105
1421
|
const q = queues.get(projectId);
|
|
1106
1422
|
// sortQueueByPriority is a stable sort: it only ever moves a task ahead of
|
|
1107
1423
|
// a lower-priority one, never disturbs relative order within the same
|
|
@@ -2063,7 +2379,7 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
2063
2379
|
`ワーカー報告:\n${reports.slice(0, 1500)}`,
|
|
2064
2380
|
].join('\n');
|
|
2065
2381
|
try {
|
|
2066
|
-
const r = await
|
|
2382
|
+
const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
|
|
2067
2383
|
const m = r.result.match(/\{[\s\S]*\}/);
|
|
2068
2384
|
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 }; }
|
|
2069
2385
|
} catch { /* fall through */ }
|
|
@@ -2530,7 +2846,7 @@ const server = createServer(async (req, res) => {
|
|
|
2530
2846
|
price: await getBillingPrice(),
|
|
2531
2847
|
siteUrl: BILLING_API_URL || null,
|
|
2532
2848
|
};
|
|
2533
|
-
return json(res, 200, { projects, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents:
|
|
2849
|
+
return json(res, 200, { projects, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents: AVAILABLE_AGENTS, connectCommand, queueOrder, externalActivity, workflowColumns: projectColumns, reviewDefinitions: projectReviewDefs, runningCounts, parallelLimits, uiVersion: uiVersion(), billing, auth: publicAuth() });
|
|
2534
2850
|
}
|
|
2535
2851
|
|
|
2536
2852
|
// Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
|
|
@@ -2552,6 +2868,34 @@ const server = createServer(async (req, res) => {
|
|
|
2552
2868
|
return;
|
|
2553
2869
|
}
|
|
2554
2870
|
|
|
2871
|
+
// GET /api/relay-status — is this account's relay slot held by ANOTHER device?
|
|
2872
|
+
// The relay-client (a separate process) writes relay-status.json when it stands
|
|
2873
|
+
// down after being evicted (see relay-client.mjs / lib.mjs relayReconnectDecision).
|
|
2874
|
+
// P4's board reads this to show "Galda is running on another device → switch
|
|
2875
|
+
// here". Best-effort: no file yet → idle.
|
|
2876
|
+
if (url.pathname === '/api/relay-status' && req.method === 'GET') {
|
|
2877
|
+
let st = { status: 'idle', stoodDown: false, identity: null, at: 0 };
|
|
2878
|
+
try { st = { ...st, ...JSON.parse(readFileSync(join(DATA_DIR, 'relay-status.json'), 'utf8')) }; } catch { /* none yet */ }
|
|
2879
|
+
return json(res, 200, { ...st, inUseElsewhere: st.stoodDown === true });
|
|
2880
|
+
}
|
|
2881
|
+
// POST /api/relay-takeover — the user pressed "switch to this device". Signal
|
|
2882
|
+
// the relay-client (separate process) via a request file it watches; it clears
|
|
2883
|
+
// the stand-down and reclaims the relay slot. UI wiring is P4.
|
|
2884
|
+
if (url.pathname === '/api/relay-takeover' && req.method === 'POST') {
|
|
2885
|
+
try { writeFileSync(join(DATA_DIR, 'relay-takeover.req'), String(Date.now())); }
|
|
2886
|
+
catch (e) { return json(res, 500, { error: String(e?.message ?? e) }); }
|
|
2887
|
+
return json(res, 200, { ok: true });
|
|
2888
|
+
}
|
|
2889
|
+
// POST /api/signout — forget the local license so the settings panel can
|
|
2890
|
+
// offer a plain "Log out" instead of requiring the `--signin` CLI flag to
|
|
2891
|
+
// switch Google accounts. Clears the on-disk token and the in-memory cache;
|
|
2892
|
+
// idempotent when already signed out.
|
|
2893
|
+
if (url.pathname === '/api/signout' && req.method === 'POST') {
|
|
2894
|
+
if (existsSync(licenseFile)) unlinkSync(licenseFile);
|
|
2895
|
+
licenseState = { email: null, isPaying: false, verifiedAt: 0 };
|
|
2896
|
+
return json(res, 200, { ok: true });
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2555
2899
|
// GET /api/signin-url — mints a one-time nonce and returns the billing
|
|
2556
2900
|
// Worker's /connect URL for "Sign in with Google". The app opens this in the
|
|
2557
2901
|
// browser; Google bounces back to /oauth/callback (below) with a token.
|
|
@@ -2620,6 +2964,15 @@ const server = createServer(async (req, res) => {
|
|
|
2620
2964
|
return json(res, 200, { licensed: true, isPaying: licenseState.isPaying });
|
|
2621
2965
|
}
|
|
2622
2966
|
|
|
2967
|
+
// Live cross-device takeover (P4): pull the account's newer board into this
|
|
2968
|
+
// running instance on demand (the switch UI's 切替 button). Adopts only when
|
|
2969
|
+
// this device is idle; 409 'busy' if it has active work (never yanks the board
|
|
2970
|
+
// from a live worker). Key-gated like every /api route above.
|
|
2971
|
+
if (url.pathname === '/api/sync/pull' && req.method === 'POST') {
|
|
2972
|
+
const r = await livePull();
|
|
2973
|
+
return json(res, r.action === 'busy' ? 409 : 200, r);
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2623
2976
|
if (url.pathname === '/api/projects' && req.method === 'POST') {
|
|
2624
2977
|
let body = '';
|
|
2625
2978
|
req.on('data', (d) => { body += d; });
|
|
@@ -2701,6 +3054,32 @@ const server = createServer(async (req, res) => {
|
|
|
2701
3054
|
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
|
|
2702
3055
|
const project = projects.find((p) => p.id === projectId);
|
|
2703
3056
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
3057
|
+
// Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
|
|
3058
|
+
// composer is conversation-first. A chat/help/settings message ("what can
|
|
3059
|
+
// you do?", "reply in English", a greeting) gets a conversational reply
|
|
3060
|
+
// and must NOT become a To-Do. Classified BEFORE dedupe/billing/goal
|
|
3061
|
+
// creation, so a chat turn never folds into a goal, never counts against
|
|
3062
|
+
// the free-tier cap, and never persists a task. /later (pending) and
|
|
3063
|
+
// /review are explicit user intent → skip classification. A cheap
|
|
3064
|
+
// heuristic short-circuits obvious implementation requests (no LLM);
|
|
3065
|
+
// anything else gets one Haiku round-trip that both classifies and writes
|
|
3066
|
+
// the reply. LLM failure falls back to 'task' — never drop a real
|
|
3067
|
+
// implementation ask (the folder guard below still prevents no-ops).
|
|
3068
|
+
if (!pending && !review && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
|
|
3069
|
+
// Only a HIGH-confidence chat/help/settings message reaches the LLM —
|
|
3070
|
+
// to write the reply, and to let the model veto back to 'task' if the
|
|
3071
|
+
// heuristic over-fired. Everything else (clear tasks AND ambiguous
|
|
3072
|
+
// 'unsure') goes straight to goal creation: bias to task keeps the
|
|
3073
|
+
// common path LLM-free (no latency/tokens on real work) and the folder
|
|
3074
|
+
// guard below still prevents no-ops. LLM failure → treat as task and
|
|
3075
|
+
// fall through, so a real implementation ask is never dropped.
|
|
3076
|
+
const intentLang = detectRequestLanguage(text);
|
|
3077
|
+
try {
|
|
3078
|
+
const r = await runUtility({ prompt: buildIntentPrompt(text.trim(), intentLang), cwd: ROOT, tools: 'Read' });
|
|
3079
|
+
const parsed = parseIntentResponse(r.result);
|
|
3080
|
+
if (parsed.intent === 'chat') return json(res, 200, { kind: 'chat', reply: parsed.reply.slice(0, 2000), lang: intentLang });
|
|
3081
|
+
} catch { /* LLM unavailable → fall through to goal creation */ }
|
|
3082
|
+
}
|
|
2704
3083
|
// Dedupe (Masa: 被ったやつもまとめて): if this near-duplicates a goal
|
|
2705
3084
|
// already in flight (running/review with a live worker session), fold it
|
|
2706
3085
|
// in as a thread follow-up (context kept via session resume) instead of
|
|
@@ -2746,15 +3125,25 @@ const server = createServer(async (req, res) => {
|
|
|
2746
3125
|
// for pending/review items (they never spawn a worker).
|
|
2747
3126
|
const runnable = !pending && !review;
|
|
2748
3127
|
const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
|
|
2749
|
-
const
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
3128
|
+
const lang = /[-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
|
|
3129
|
+
const folderQuestion = needsFolder ? buildFolderQuestion(detectRepos(HOME), { lang }) : undefined;
|
|
3130
|
+
// Task 5 (PR運用ヒアリング): once the folder is settled, ask — once per
|
|
3131
|
+
// project, before its first runnable goal — whether this project wants
|
|
3132
|
+
// PRs. Skipped this round if a folder question already claimed the
|
|
3133
|
+
// needsInput slot; that goal gets rebound to a real repo via /answer
|
|
3134
|
+
// and the NEXT goal for that project is what actually asks this.
|
|
3135
|
+
const needsReviewPref = runnable && !needsFolder && needsReviewPreference({ askedBefore: project.reviewPrefAsked });
|
|
3136
|
+
const reviewPrefQuestion = needsReviewPref ? buildReviewPreferenceQuestion({ lang }) : undefined;
|
|
3137
|
+
if (needsReviewPref) { project.reviewPrefAsked = true; saveProjects(); }
|
|
3138
|
+
// Clamp to whatever agent CLIs are actually installed on this machine
|
|
3139
|
+
// (connected-agents Phase A) — a requested/default agent that isn't
|
|
3140
|
+
// present would otherwise spawn a worker doomed to fail at launch.
|
|
3141
|
+
const goalAgent = pickAvailableAgent(workerAgent(agent), AVAILABLE_AGENTS);
|
|
2753
3142
|
const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
|
|
2754
3143
|
const goal = {
|
|
2755
3144
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
2756
|
-
status: needsFolder ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
2757
|
-
question: folderQuestion,
|
|
3145
|
+
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
3146
|
+
question: folderQuestion || reviewPrefQuestion,
|
|
2758
3147
|
reviewOnly: review ? true : undefined,
|
|
2759
3148
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
2760
3149
|
wantsPR: review ? false : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
@@ -2784,7 +3173,7 @@ const server = createServer(async (req, res) => {
|
|
|
2784
3173
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
2785
3174
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
2786
3175
|
// are terminal-until-human too, so they also skip planGoal.
|
|
2787
|
-
if (runnable && !needsFolder) {
|
|
3176
|
+
if (runnable && !needsFolder && !needsReviewPref) {
|
|
2788
3177
|
planGoal(goal).catch((e) => {
|
|
2789
3178
|
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
2790
3179
|
});
|
|
@@ -2819,7 +3208,7 @@ const server = createServer(async (req, res) => {
|
|
|
2819
3208
|
'',
|
|
2820
3209
|
`質問: ${question.trim().slice(0, 500)}`,
|
|
2821
3210
|
].join('\n');
|
|
2822
|
-
const r = await
|
|
3211
|
+
const r = await runUtility({ prompt, cwd: ROOT, tools: 'Read' });
|
|
2823
3212
|
json(res, 200, { answer: r.result.trim().slice(0, 2000) });
|
|
2824
3213
|
} catch (e) {
|
|
2825
3214
|
json(res, 500, { error: String(e.message ?? e).slice(0, 200) });
|
|
@@ -2850,7 +3239,7 @@ const server = createServer(async (req, res) => {
|
|
|
2850
3239
|
const q = queues.get(goal.projectId);
|
|
2851
3240
|
if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
|
|
2852
3241
|
goals = goals.filter((g) => g.id !== goal.id);
|
|
2853
|
-
|
|
3242
|
+
logAppend(JSON.stringify({ kind: 'goal', id: goal.id, deleted: true }));
|
|
2854
3243
|
sendGoalEvent({ ev: 'goal-deleted', id: goal.id }, goal);
|
|
2855
3244
|
startNextStacked(goal.projectId);
|
|
2856
3245
|
pump(goal.projectId);
|
|
@@ -3100,6 +3489,52 @@ const server = createServer(async (req, res) => {
|
|
|
3100
3489
|
planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
|
|
3101
3490
|
return json(res, 200, goal);
|
|
3102
3491
|
};
|
|
3492
|
+
// Intent router (no free-text box is a dead-end). Before any per-kind
|
|
3493
|
+
// handler treats `answer` as a literal, classify it. A reply that isn't a
|
|
3494
|
+
// clean answer is NEVER coerced (the old bug where 「日本語で教えて」 typed
|
|
3495
|
+
// into the folder box became a repo of that name). Two non-answer routes:
|
|
3496
|
+
// relocalize → the user asked to switch language; re-render the SAME
|
|
3497
|
+
// card in that language (folder/init via their builders; a general
|
|
3498
|
+
// planner card via the model, which can restate in any language).
|
|
3499
|
+
// escalate → free-form intent; for the filesystem-bound folder cards we
|
|
3500
|
+
// re-ask (with a localized hint) rather than invent a path; for a
|
|
3501
|
+
// general card, prose is a legit clarification and routes 'accept'.
|
|
3502
|
+
const route = routeQuestionAnswer(answer, goal.question || {});
|
|
3503
|
+
const qKind = goal.question?.kind;
|
|
3504
|
+
if (route.route === 'relocalize') {
|
|
3505
|
+
goal.lang = route.lang;
|
|
3506
|
+
if (qKind === 'folder') {
|
|
3507
|
+
goal.question = buildFolderQuestion(detectRepos(HOME), { lang: route.lang });
|
|
3508
|
+
saveGoal(goal);
|
|
3509
|
+
return json(res, 200, goal);
|
|
3510
|
+
}
|
|
3511
|
+
if (qKind === 'folder-init') {
|
|
3512
|
+
const q0 = goal.question;
|
|
3513
|
+
goal.question = buildFolderInitConfirm(q0.dir, { lang: route.lang, create: q0.create, homeDir: HOME });
|
|
3514
|
+
saveGoal(goal);
|
|
3515
|
+
return json(res, 200, goal);
|
|
3516
|
+
}
|
|
3517
|
+
// General planner clarify card: no builder to rebuild from, so ask the
|
|
3518
|
+
// model (cheap, Read-only) to restate the pending question in `route.lang`.
|
|
3519
|
+
relocalizeGeneralQuestion(goal, route.lang, res);
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
3522
|
+
if (route.route === 'escalate' && (qKind === 'folder' || qKind === 'folder-init')) {
|
|
3523
|
+
// Couldn't read prose as a folder/decision → re-ask WITHOUT coercion,
|
|
3524
|
+
// folding a localized hint into the card's text (no new UI field).
|
|
3525
|
+
const lang = goal.question?.lang || goal.lang || 'ja';
|
|
3526
|
+
if (qKind === 'folder-init') {
|
|
3527
|
+
const q0 = goal.question;
|
|
3528
|
+
goal.question = buildFolderInitConfirm(q0.dir, { lang, create: q0.create, homeDir: HOME });
|
|
3529
|
+
} else {
|
|
3530
|
+
goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
|
|
3531
|
+
}
|
|
3532
|
+
goal.question.text = notUnderstoodNote(lang) + goal.question.text;
|
|
3533
|
+
saveGoal(goal);
|
|
3534
|
+
return json(res, 200, goal);
|
|
3535
|
+
}
|
|
3536
|
+
// route === 'accept' (or a general escalate, which folds into goal.text
|
|
3537
|
+
// below) → fall through to the existing per-kind handlers unchanged.
|
|
3103
3538
|
// Folder-choice answer (onboarding funnel): rebind the goal to the chosen
|
|
3104
3539
|
// repo rather than appending to goal.text — a folder isn't a task
|
|
3105
3540
|
// clarification. If the picked path is already a repo, bind it. If it's a
|
|
@@ -3157,6 +3592,28 @@ const server = createServer(async (req, res) => {
|
|
|
3157
3592
|
}
|
|
3158
3593
|
return bindAndPlan(dir);
|
|
3159
3594
|
}
|
|
3595
|
+
// Review-preference answer (task 5 onboarding funnel): fold into the
|
|
3596
|
+
// project's review definition (defaultWantsPR) rather than goal.text —
|
|
3597
|
+
// like the folder question, this is project setup, not a task detail.
|
|
3598
|
+
if (goal.question?.kind === 'reviewPref') {
|
|
3599
|
+
const project = projects.find((p) => p.id === goal.projectId);
|
|
3600
|
+
const patch = resolveReviewPreferenceAnswer(answer, goal.question);
|
|
3601
|
+
if (project) {
|
|
3602
|
+
const merged = validateReviewDefinition({ ...getReviewDefinition(project.id), ...patch });
|
|
3603
|
+
if (merged.ok) {
|
|
3604
|
+
reviewDefinitions[project.id] = merged.definition;
|
|
3605
|
+
saveReviewDefinitions();
|
|
3606
|
+
send({ ev: 'review-definition', projectId: project.id, reviewDefinition: merged.definition });
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
goal.wantsPR = resolveWantsPR(undefined, goal.text, patch.defaultWantsPR);
|
|
3610
|
+
goal.question = null;
|
|
3611
|
+
goal.clarified = true;
|
|
3612
|
+
goal.status = 'planning';
|
|
3613
|
+
saveGoal(goal);
|
|
3614
|
+
planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
|
|
3615
|
+
return json(res, 200, goal);
|
|
3616
|
+
}
|
|
3160
3617
|
goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
|
|
3161
3618
|
goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
|
|
3162
3619
|
goal.question = null;
|
|
@@ -3540,6 +3997,7 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
3540
3997
|
spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
|
|
3541
3998
|
}
|
|
3542
3999
|
console.log(`[manager] projects: ${projects.map((p) => `${p.id}=${p.dir}`).join(' ')}`);
|
|
4000
|
+
probeWorkerAuth().catch(() => {}); // hold the queue + banner if `claude` isn't signed in
|
|
3543
4001
|
syncAllExternal().catch(() => {});
|
|
3544
4002
|
syncGoalMerges().catch(() => {});
|
|
3545
4003
|
setInterval(() => {
|