@galda/cli 0.10.7 → 0.10.8
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/engine/lib.mjs +52 -0
- package/engine/server.mjs +74 -3
- package/package.json +1 -1
package/engine/lib.mjs
CHANGED
|
@@ -2400,3 +2400,55 @@ export async function verifyLicenseToken({ token, publicJwk = LICENSE_PUBLIC_JWK
|
|
|
2400
2400
|
export function shouldEmitSetupCompleted({ isMcpClient, alreadySeen, flagExists }) {
|
|
2401
2401
|
return Boolean(isMcpClient) && !alreadySeen && !flagExists;
|
|
2402
2402
|
}
|
|
2403
|
+
|
|
2404
|
+
// Onboarding funnel: a goal can only produce real changes if it runs inside a
|
|
2405
|
+
// real code repo. When the agent is started ad-hoc (e.g. `node bin/...` in the
|
|
2406
|
+
// user's HOME), the seeded default project points at a non-repo dir → the
|
|
2407
|
+
// worker no-ops (nothing to edit) yet the goal is marked "done" — the exact
|
|
2408
|
+
// blocker for "実装が始まる". This decides, BEFORE running, whether we must ask
|
|
2409
|
+
// the user which folder to work in. `isRepo` is passed in (the git probe is
|
|
2410
|
+
// I/O, done by the caller) so this stays pure/testable. Home is never a valid
|
|
2411
|
+
// work root — even a git-init'd home dir is "no project chosen yet".
|
|
2412
|
+
export function needsProjectFolder({ dir, homeDir, isRepo } = {}) {
|
|
2413
|
+
if (!dir) return true;
|
|
2414
|
+
const norm = (p) => String(p).replace(/\/+$/, '');
|
|
2415
|
+
if (homeDir && norm(dir) === norm(homeDir)) return true;
|
|
2416
|
+
return !isRepo;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// A short, ≤60-char display label for a repo dir, shown home-relative (~/…) so
|
|
2420
|
+
// the folder-choice options read cleanly on the board card.
|
|
2421
|
+
export function folderLabel(dir, homeDir = '') {
|
|
2422
|
+
if (!dir) return '';
|
|
2423
|
+
let d = String(dir);
|
|
2424
|
+
const h = String(homeDir || '').replace(/\/+$/, '');
|
|
2425
|
+
if (h && (d === h || d.startsWith(h + '/'))) d = '~' + d.slice(h.length);
|
|
2426
|
+
return d.length <= 60 ? d : '…' + d.slice(-59);
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// Build the "which folder should I work in?" clarifying question from detected
|
|
2430
|
+
// repos. Reuses the existing needsInput card ({text, options:string[]}), and
|
|
2431
|
+
// adds kind:'folder' + folders[] + lang so the /answer handler rebinds the
|
|
2432
|
+
// goal's project to the chosen repo (instead of appending to goal.text like a
|
|
2433
|
+
// task clarification). `folders` = [{label, dir}]. Options are capped/shaped to
|
|
2434
|
+
// what the board expects (≤8 entries, each ≤60 chars) with a trailing
|
|
2435
|
+
// "somewhere else" escape for a typed path.
|
|
2436
|
+
export function buildFolderQuestion(folders = [], { lang = 'ja' } = {}) {
|
|
2437
|
+
const capped = folders.slice(0, 7).map((f) => ({ label: String(f.label || f.dir || ''), dir: f.dir }));
|
|
2438
|
+
const other = lang === 'en' ? 'Somewhere else…' : '別の場所を指定…';
|
|
2439
|
+
const text = lang === 'en' ? 'Which folder should I work in?' : 'どのフォルダで実装しますか?';
|
|
2440
|
+
return { text, options: [...capped.map((f) => f.label), other], folders: capped, kind: 'folder', lang, other };
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// Resolve a folder-choice answer (a picked option label, or a typed path) back
|
|
2444
|
+
// to an absolute dir. Returns { dir } or null (→ the "somewhere else" sentinel /
|
|
2445
|
+
// an unrecognized answer, which the caller re-asks on). Match by label first,
|
|
2446
|
+
// then exact dir, then treat an absolute-looking answer as a literal path.
|
|
2447
|
+
export function resolveFolderAnswer(answer, folders = []) {
|
|
2448
|
+
const a = String(answer ?? '').trim();
|
|
2449
|
+
if (!a) return null;
|
|
2450
|
+
const hit = folders.find((f) => f.label === a || f.dir === a);
|
|
2451
|
+
if (hit) return { dir: hit.dir };
|
|
2452
|
+
if (a.startsWith('/') || a.startsWith('~')) return { dir: a };
|
|
2453
|
+
return null;
|
|
2454
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
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 } from 'node:fs';
|
|
16
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
17
17
|
import { emitEvent } from './analytics-client.mjs';
|
|
18
18
|
import { randomBytes, createHash } 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 } from './lib.mjs';
|
|
23
24
|
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, 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 } from './lib.mjs';
|
|
24
25
|
import { openPR } from './pr.mjs';
|
|
25
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
@@ -428,6 +429,10 @@ async function authFromCfAccess(req) {
|
|
|
428
429
|
}
|
|
429
430
|
}
|
|
430
431
|
|
|
432
|
+
// Where to look for the user's repos (folder-choice options) and which dir is
|
|
433
|
+
// "not a real project" (never a valid work root). Defaults to the OS home;
|
|
434
|
+
// overridable for tests / operators whose repos live elsewhere.
|
|
435
|
+
const HOME = process.env.MANAGER_SCAN_HOME || homedir();
|
|
431
436
|
const projectsFile = join(DATA_DIR, 'projects.json');
|
|
432
437
|
if (!existsSync(projectsFile)) {
|
|
433
438
|
const cwd = process.cwd();
|
|
@@ -784,6 +789,30 @@ function repoDefaultBranch(dir) {
|
|
|
784
789
|
try { return gitSync(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, ''); }
|
|
785
790
|
catch { return 'main'; }
|
|
786
791
|
}
|
|
792
|
+
// Non-throwing "is this a real git work tree?" probe. The onboarding guard uses
|
|
793
|
+
// it to refuse running a goal against a non-repo (which would no-op) and to
|
|
794
|
+
// validate a folder the user picks.
|
|
795
|
+
function isGitRepo(dir) {
|
|
796
|
+
try { return !!dir && existsSync(dir) && gitSync(dir, ['rev-parse', '--is-inside-work-tree']) === 'true'; }
|
|
797
|
+
catch { return false; }
|
|
798
|
+
}
|
|
799
|
+
// Shallow-scan the user's HOME for candidate repos to offer as folder choices
|
|
800
|
+
// (depth-1: direct children of HOME with a `.git` — matches how devs keep repos
|
|
801
|
+
// side by side, and stays fast/safe vs. a deep `find`). The typed-path escape
|
|
802
|
+
// in the folder question covers anything deeper.
|
|
803
|
+
function detectRepos(homeDir) {
|
|
804
|
+
const out = [];
|
|
805
|
+
try {
|
|
806
|
+
for (const name of readdirSync(homeDir)) {
|
|
807
|
+
if (name.startsWith('.')) continue;
|
|
808
|
+
const dir = join(homeDir, name);
|
|
809
|
+
try {
|
|
810
|
+
if (statSync(dir).isDirectory() && existsSync(join(dir, '.git'))) out.push({ dir, label: folderLabel(dir, homeDir) });
|
|
811
|
+
} catch { /* unreadable entry — skip */ }
|
|
812
|
+
}
|
|
813
|
+
} catch { /* unreadable home — return whatever we have */ }
|
|
814
|
+
return out.slice(0, 12);
|
|
815
|
+
}
|
|
787
816
|
// Each goal works in its OWN git worktree, branched fresh from origin/<default>,
|
|
788
817
|
// under <repo>/.manager-wt/goal-<id>. So: (1) the user's real working dir is
|
|
789
818
|
// never touched; (2) each goal's diff contains only that goal's changes on top
|
|
@@ -2661,11 +2690,25 @@ const server = createServer(async (req, res) => {
|
|
|
2661
2690
|
// no PR) — the reviewer just Approves (files it) or Dismisses (drops
|
|
2662
2691
|
// it). This is how "needs my judgment" reaches the phone without kicking
|
|
2663
2692
|
// off an implementation run.
|
|
2693
|
+
// Onboarding funnel (docs/HANDOFF-ONBOARDING-conversational.md): a real
|
|
2694
|
+
// implementation goal can only produce changes inside a real code repo.
|
|
2695
|
+
// If the active project points at a non-repo (e.g. the HOME dir when the
|
|
2696
|
+
// agent was started ad-hoc), running would no-op — nothing to edit — yet
|
|
2697
|
+
// report "done". Instead of that fake "fixed", ask which folder first,
|
|
2698
|
+
// reusing the needsInput clarifying card; the answer (POST /answer)
|
|
2699
|
+
// rebinds this goal to the chosen repo, then planning proceeds. Skipped
|
|
2700
|
+
// for pending/review items (they never spawn a worker).
|
|
2701
|
+
const runnable = !pending && !review;
|
|
2702
|
+
const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
|
|
2703
|
+
const folderQuestion = needsFolder
|
|
2704
|
+
? buildFolderQuestion(detectRepos(HOME), { lang: /[-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en' })
|
|
2705
|
+
: undefined;
|
|
2664
2706
|
const goalAgent = workerAgent(agent);
|
|
2665
2707
|
const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
|
|
2666
2708
|
const goal = {
|
|
2667
2709
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
2668
|
-
status: review ? 'review' : pending ? 'pending' : 'planning',
|
|
2710
|
+
status: needsFolder ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
|
|
2711
|
+
question: folderQuestion,
|
|
2669
2712
|
reviewOnly: review ? true : undefined,
|
|
2670
2713
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
2671
2714
|
wantsPR: review ? false : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
@@ -2695,7 +2738,7 @@ const server = createServer(async (req, res) => {
|
|
|
2695
2738
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
2696
2739
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
2697
2740
|
// are terminal-until-human too, so they also skip planGoal.
|
|
2698
|
-
if (
|
|
2741
|
+
if (runnable && !needsFolder) {
|
|
2699
2742
|
planGoal(goal).catch((e) => {
|
|
2700
2743
|
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
2701
2744
|
});
|
|
@@ -2994,6 +3037,34 @@ const server = createServer(async (req, res) => {
|
|
|
2994
3037
|
let answer = '';
|
|
2995
3038
|
try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
|
|
2996
3039
|
if (!answer) return json(res, 400, { error: 'answer required' });
|
|
3040
|
+
// Folder-choice answer (onboarding funnel): rebind the goal to the chosen
|
|
3041
|
+
// repo rather than appending to goal.text — a folder isn't a task
|
|
3042
|
+
// clarification. Validate it's a real repo (and not HOME); if not, re-ask
|
|
3043
|
+
// with a fresh detected list so we never fall through to a no-op run.
|
|
3044
|
+
if (goal.question?.kind === 'folder') {
|
|
3045
|
+
const resolved = resolveFolderAnswer(answer, goal.question.folders || []);
|
|
3046
|
+
let dir = resolved?.dir || answer;
|
|
3047
|
+
if (dir.startsWith('~')) dir = join(HOME, dir.slice(1).replace(/^\/+/, ''));
|
|
3048
|
+
if (dir !== HOME && isGitRepo(dir)) {
|
|
3049
|
+
const existing = projects.find((p) => p.dir === dir);
|
|
3050
|
+
let proj = existing;
|
|
3051
|
+
if (!proj) {
|
|
3052
|
+
proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
|
|
3053
|
+
projects.push(proj); saveProjects();
|
|
3054
|
+
}
|
|
3055
|
+
goal.projectId = proj.id;
|
|
3056
|
+
goal.question = null;
|
|
3057
|
+
goal.clarified = true;
|
|
3058
|
+
goal.status = 'planning';
|
|
3059
|
+
saveGoal(goal);
|
|
3060
|
+
planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
|
|
3061
|
+
return json(res, 200, goal);
|
|
3062
|
+
}
|
|
3063
|
+
// not a usable repo → keep asking (don't run a no-op)
|
|
3064
|
+
goal.question = buildFolderQuestion(detectRepos(HOME), { lang: goal.question.lang || 'ja' });
|
|
3065
|
+
saveGoal(goal);
|
|
3066
|
+
return json(res, 200, goal);
|
|
3067
|
+
}
|
|
2997
3068
|
goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
|
|
2998
3069
|
goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
|
|
2999
3070
|
goal.question = null;
|
package/package.json
CHANGED