@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 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 (!pending && !review) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.7",
3
+ "version": "0.10.8",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to your Claude Code, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {