@galda/cli 0.10.17 → 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 +376 -62
- package/bin/manager-for-ai.mjs +18 -12
- package/engine/lib.mjs +470 -12
- package/engine/relay-client.mjs +101 -32
- package/engine/server.mjs +439 -19
- 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, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse } 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
|
|
@@ -591,7 +632,7 @@ function sendGoalEvent(obj, goal) {
|
|
|
591
632
|
// proceed regardless. Durability is best-effort here; a live UI wins over a
|
|
592
633
|
// perfectly-replayable log on a disk that can't be written to anyway.
|
|
593
634
|
function logAppend(line) {
|
|
594
|
-
try { appendFileSync(logFile, line + '\n'); return true; }
|
|
635
|
+
try { appendFileSync(logFile, line + '\n'); markSyncDirty(); return true; }
|
|
595
636
|
catch (e) { console.error(`[manager] log write failed (${e?.code || e}) — state not persisted this write, UI still updated`); return false; }
|
|
596
637
|
}
|
|
597
638
|
function saveGoal(g) { logAppend(JSON.stringify({ ...g, kind: 'goal' })); sendGoalEvent({ ev: 'goal', goal: g }, g); }
|
|
@@ -660,6 +701,202 @@ function postHistory(t) {
|
|
|
660
701
|
}).catch(() => {});
|
|
661
702
|
} catch { /* never throw into the caller */ }
|
|
662
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
|
+
|
|
663
900
|
const ACTIVITY_LIMIT = 1000;
|
|
664
901
|
function activityLine(line) {
|
|
665
902
|
return String(line ?? '')
|
|
@@ -777,6 +1014,13 @@ function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model,
|
|
|
777
1014
|
});
|
|
778
1015
|
});
|
|
779
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
|
+
}
|
|
780
1024
|
function runWorkerAgent({ agent, ...args }) {
|
|
781
1025
|
return workerAgent(agent) === 'codex' ? runCodex(args) : runClaude(args);
|
|
782
1026
|
}
|
|
@@ -998,6 +1242,62 @@ function startNextStacked(projectId) {
|
|
|
998
1242
|
});
|
|
999
1243
|
}
|
|
1000
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
|
+
|
|
1001
1301
|
async function planGoal(goal) {
|
|
1002
1302
|
const project = projects.find((p) => p.id === goal.projectId);
|
|
1003
1303
|
const prompt = [
|
|
@@ -1114,6 +1414,10 @@ async function authorVerify(task, goal, project) {
|
|
|
1114
1414
|
// `lastStartAt` check in pump() below.
|
|
1115
1415
|
const slowModeLastStart = new Map(); // projectId -> ms epoch of the last task start
|
|
1116
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;
|
|
1117
1421
|
const q = queues.get(projectId);
|
|
1118
1422
|
// sortQueueByPriority is a stable sort: it only ever moves a task ahead of
|
|
1119
1423
|
// a lower-priority one, never disturbs relative order within the same
|
|
@@ -2075,7 +2379,7 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
2075
2379
|
`ワーカー報告:\n${reports.slice(0, 1500)}`,
|
|
2076
2380
|
].join('\n');
|
|
2077
2381
|
try {
|
|
2078
|
-
const r = await
|
|
2382
|
+
const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
|
|
2079
2383
|
const m = r.result.match(/\{[\s\S]*\}/);
|
|
2080
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 }; }
|
|
2081
2385
|
} catch { /* fall through */ }
|
|
@@ -2542,7 +2846,7 @@ const server = createServer(async (req, res) => {
|
|
|
2542
2846
|
price: await getBillingPrice(),
|
|
2543
2847
|
siteUrl: BILLING_API_URL || null,
|
|
2544
2848
|
};
|
|
2545
|
-
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() });
|
|
2546
2850
|
}
|
|
2547
2851
|
|
|
2548
2852
|
// Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
|
|
@@ -2564,6 +2868,34 @@ const server = createServer(async (req, res) => {
|
|
|
2564
2868
|
return;
|
|
2565
2869
|
}
|
|
2566
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
|
+
|
|
2567
2899
|
// GET /api/signin-url — mints a one-time nonce and returns the billing
|
|
2568
2900
|
// Worker's /connect URL for "Sign in with Google". The app opens this in the
|
|
2569
2901
|
// browser; Google bounces back to /oauth/callback (below) with a token.
|
|
@@ -2632,6 +2964,15 @@ const server = createServer(async (req, res) => {
|
|
|
2632
2964
|
return json(res, 200, { licensed: true, isPaying: licenseState.isPaying });
|
|
2633
2965
|
}
|
|
2634
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
|
+
|
|
2635
2976
|
if (url.pathname === '/api/projects' && req.method === 'POST') {
|
|
2636
2977
|
let body = '';
|
|
2637
2978
|
req.on('data', (d) => { body += d; });
|
|
@@ -2734,7 +3075,7 @@ const server = createServer(async (req, res) => {
|
|
|
2734
3075
|
// fall through, so a real implementation ask is never dropped.
|
|
2735
3076
|
const intentLang = detectRequestLanguage(text);
|
|
2736
3077
|
try {
|
|
2737
|
-
const r = await
|
|
3078
|
+
const r = await runUtility({ prompt: buildIntentPrompt(text.trim(), intentLang), cwd: ROOT, tools: 'Read' });
|
|
2738
3079
|
const parsed = parseIntentResponse(r.result);
|
|
2739
3080
|
if (parsed.intent === 'chat') return json(res, 200, { kind: 'chat', reply: parsed.reply.slice(0, 2000), lang: intentLang });
|
|
2740
3081
|
} catch { /* LLM unavailable → fall through to goal creation */ }
|
|
@@ -2784,15 +3125,25 @@ const server = createServer(async (req, res) => {
|
|
|
2784
3125
|
// for pending/review items (they never spawn a worker).
|
|
2785
3126
|
const runnable = !pending && !review;
|
|
2786
3127
|
const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
|
|
2787
|
-
const
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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);
|
|
2791
3142
|
const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
|
|
2792
3143
|
const goal = {
|
|
2793
3144
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
2794
|
-
status: needsFolder ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
2795
|
-
question: folderQuestion,
|
|
3145
|
+
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
3146
|
+
question: folderQuestion || reviewPrefQuestion,
|
|
2796
3147
|
reviewOnly: review ? true : undefined,
|
|
2797
3148
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
2798
3149
|
wantsPR: review ? false : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
@@ -2822,7 +3173,7 @@ const server = createServer(async (req, res) => {
|
|
|
2822
3173
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
2823
3174
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
2824
3175
|
// are terminal-until-human too, so they also skip planGoal.
|
|
2825
|
-
if (runnable && !needsFolder) {
|
|
3176
|
+
if (runnable && !needsFolder && !needsReviewPref) {
|
|
2826
3177
|
planGoal(goal).catch((e) => {
|
|
2827
3178
|
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
2828
3179
|
});
|
|
@@ -2857,7 +3208,7 @@ const server = createServer(async (req, res) => {
|
|
|
2857
3208
|
'',
|
|
2858
3209
|
`質問: ${question.trim().slice(0, 500)}`,
|
|
2859
3210
|
].join('\n');
|
|
2860
|
-
const r = await
|
|
3211
|
+
const r = await runUtility({ prompt, cwd: ROOT, tools: 'Read' });
|
|
2861
3212
|
json(res, 200, { answer: r.result.trim().slice(0, 2000) });
|
|
2862
3213
|
} catch (e) {
|
|
2863
3214
|
json(res, 500, { error: String(e.message ?? e).slice(0, 200) });
|
|
@@ -3138,6 +3489,52 @@ const server = createServer(async (req, res) => {
|
|
|
3138
3489
|
planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
|
|
3139
3490
|
return json(res, 200, goal);
|
|
3140
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.
|
|
3141
3538
|
// Folder-choice answer (onboarding funnel): rebind the goal to the chosen
|
|
3142
3539
|
// repo rather than appending to goal.text — a folder isn't a task
|
|
3143
3540
|
// clarification. If the picked path is already a repo, bind it. If it's a
|
|
@@ -3195,6 +3592,28 @@ const server = createServer(async (req, res) => {
|
|
|
3195
3592
|
}
|
|
3196
3593
|
return bindAndPlan(dir);
|
|
3197
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
|
+
}
|
|
3198
3617
|
goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
|
|
3199
3618
|
goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
|
|
3200
3619
|
goal.question = null;
|
|
@@ -3578,6 +3997,7 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
3578
3997
|
spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
|
|
3579
3998
|
}
|
|
3580
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
|
|
3581
4001
|
syncAllExternal().catch(() => {});
|
|
3582
4002
|
syncGoalMerges().catch(() => {});
|
|
3583
4003
|
setInterval(() => {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@galda/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.19",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Galda - hand off work to
|
|
5
|
+
"description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"start": "node engine/server.mjs",
|
|
8
8
|
"test": "node --test --test-concurrency=2 'engine/test/*.test.mjs'"
|