@galda/cli 0.10.24 → 0.10.30

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
@@ -1069,6 +1069,7 @@ export function goalGroupStatus(goalStatus, startedAt) {
1069
1069
  if (goalStatus === 'planning' || goalStatus === 'running') return 'doing';
1070
1070
  if (goalStatus === 'review') return 'review';
1071
1071
  if (goalStatus === 'retesting') return 'doing'; // Ledger Dismiss → engine re-testing ×3 (v45 §5.3)
1072
+ if (goalStatus === 'folded') return 'review'; // PRD §6.5: rendered inside the Review section, not Done
1072
1073
  if (goalStatus === 'blocked') return 'attn'; // §4E: blocked is Attention, not Done
1073
1074
  if (goalStatus === 'needsInput') return 'todo'; // smart intake: awaiting an answer
1074
1075
  return 'done'; // includes 'reverted' (terminal)
@@ -1101,6 +1102,7 @@ export function goalChip(goalStatus, columns, startedAt) {
1101
1102
  if (goalStatus === 'done') return [pickColumnLabel(columns, 'done', 'DONE'), ''];
1102
1103
  if (goalStatus === 'retesting') return ['RE-TESTING', 'doing']; // Ledger Dismiss → retest ×3 (v45 §5.3)
1103
1104
  if (goalStatus === 'reverted') return ['REVERTED', '']; // Ledger Revert: terminal, distinct from archived
1105
+ if (goalStatus === 'folded') return ['FOLDED', '']; // PRD §6.5: settled, not failed — no red/badge class, matches 'done'/'reverted'
1104
1106
  if (goalStatus === 'blocked') return ['BLOCKED', 'blocked']; // §4E: distinct state (reason + reverify), not FAIL/Done
1105
1107
  if (goalStatus === 'needsInput') return ['確認待ち', 'blocked']; // smart intake: awaiting an answer
1106
1108
  return ['FAIL', ''];
@@ -1595,6 +1597,44 @@ export function detectConflicts(goal, otherGoals) {
1595
1597
  .map((g) => g.id);
1596
1598
  }
1597
1599
 
1600
+ // ---- the review gate (PRD §6.5) --------------------------------------------
1601
+ // Detection alone was never the point: a flag that says "this conflicts with
1602
+ // #38" still leaves the human to untangle two PRs that edit the same lines.
1603
+ // PRD §6.5 requires that PRs sitting in Review never conflict WITH EACH OTHER,
1604
+ // so work that lands on the same paths must land on the SAME PR.
1605
+ //
1606
+ // This is the last of the three gates (queue / doing / review) and the only one
1607
+ // that knows the truth: by the time a goal finishes, its changed files are
1608
+ // measured, not guessed. So the promise rests here, and the earlier gates are
1609
+ // free to be wrong — being wrong costs a re-run, never a conflict.
1610
+ //
1611
+ // Why the caller re-runs the folded goal's intent inside the target's worktree
1612
+ // instead of replaying its diff: engine/pr.mjs COPIES changed files into the PR
1613
+ // worktree (cp -R) rather than merging them, so aiming this goal's PR at the
1614
+ // target's branch would silently overwrite the target's version of any shared
1615
+ // file. Re-running lets the worker SEE the target's changes and build on them,
1616
+ // which means there is never a conflict to resolve in the first place.
1617
+ //
1618
+ // `candidates` are same-project goals already awaiting review (each
1619
+ // `{ id, changedFiles }`). Returns the id to fold into, or null to open a
1620
+ // normal PR.
1621
+ //
1622
+ // Deliberately folds ONLY when exactly one review goal overlaps. With two or
1623
+ // more, folding is not merely insufficient, it is harmful: pulling goal B into
1624
+ // A's PR drags B's paths along, so A's PR starts colliding with C's where it
1625
+ // did not before. That cluster wants one shared PR, which we cannot build by
1626
+ // bolting onto an already-open one — so we leave those flagged (the old
1627
+ // behaviour) rather than make the pile worse. Known remainder, named in the
1628
+ // handoff, not silently swallowed.
1629
+ export function pickFoldTarget(goal, candidates) {
1630
+ const files = goal?.changedFiles ?? [];
1631
+ if (!files.length) return null;
1632
+ const overlapping = (candidates ?? []).filter(
1633
+ (g) => g && g.id !== goal.id && goalsConflict(files, g.changedFiles),
1634
+ );
1635
+ return overlapping.length === 1 ? overlapping[0].id : null;
1636
+ }
1637
+
1598
1638
  // Which of `commits` (newest-first, as returned by parseGitLog) are new
1599
1639
  // since we last synced up to `lastSeenHash`? null means nothing has been
1600
1640
  // seen yet, so every commit is new. If lastSeenHash isn't found in the
@@ -2735,8 +2775,11 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2735
2775
 
2736
2776
  // ---- billing entitlement gate (public self-serve launch, docs/BILLING-LAUNCH-PLAN.md) --
2737
2777
  // Mirrors workers/billing-api/src/entitlement.mjs's FREE_TIER_LIMITS/
2738
- // checkFreeTierLimit/resolveEntitlement exactly (Masa decision 2026-07-12:
2739
- // 5 pending goals, 19 lifetime goals, 1 project, $16/mo unlocks all three).
2778
+ // checkFreeTierLimit/resolveEntitlement exactly (Masa: 5 queued to-dos,
2779
+ // 20 total tasks, 1 project matching galda.app's own words. Updated
2780
+ // 2026-07-17: lifetime cap 19→20 so the wall says the same number the sales
2781
+ // page promises ("20 total tasks"); it used to block the 20th create, one short
2782
+ // of the promise. Pro unlocks all three; price is Stripe-derived, not in code.)
2740
2783
  // Deliberately duplicated rather than imported: engine/ and workers/billing-api/
2741
2784
  // are independently deployed (a user's Mac vs Cloudflare) and independently
2742
2785
  // tested — sharing a module across that boundary would couple two things
@@ -2744,10 +2787,21 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2744
2787
  // hand; both have their own test coverage against the same free-tier numbers.
2745
2788
  export const FREE_TIER_LIMITS = Object.freeze({
2746
2789
  maxPendingGoals: 5,
2747
- maxCumulativeGoals: 19,
2790
+ maxCumulativeGoals: 20,
2748
2791
  maxProjects: 1,
2749
2792
  });
2750
2793
 
2794
+ // The statuses that occupy a "queued to-do" slot (the 5-slot wall). Narrower
2795
+ // than "every open goal" on purpose: a goal in `review` has finished its work
2796
+ // and is waiting on a human — that's not a to-do in flight, and galda.app sells
2797
+ // the slot as "queued to-dos", so review must not eat one (Masa 2026-07-17,
2798
+ // "5枠は実行待ちだけ"). Kept as its own named list rather than filtering an
2799
+ // "all open" constant so the intent is legible where it's read.
2800
+ export const QUEUED_GOAL_STATUSES = Object.freeze([
2801
+ 'stacked', 'sending', 'pending', 'planning', 'needsInput',
2802
+ 'running', 'partial', 'failed', 'interrupted', 'blocked', 'retesting',
2803
+ ]);
2804
+
2751
2805
  export function checkFreeTierLimit(usage) {
2752
2806
  const pendingCount = Number(usage?.pendingCount) || 0;
2753
2807
  const cumulativeCount = Number(usage?.cumulativeCount) || 0;
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } 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';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -177,6 +177,19 @@ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
177
177
  // it survives restarts.
178
178
  const licenseFile = join(DATA_DIR, 'license.token');
179
179
  let licenseState = { email: null, isPaying: false, verifiedAt: 0 }; // in-memory cache of the last successful verify (isPaying is the token's snapshot; the live gate re-checks)
180
+ // Test-only seam: override the license-verify public key via env so tests can
181
+ // sign tokens the running server actually accepts (the baked LICENSE_PUBLIC_JWK
182
+ // has no committed private half, so the licensed-but-lapsed path is otherwise
183
+ // untestable — see docs/HANDOFF-A2C-WORKERS-DEV-CLEANUP.md item 6). Unset in
184
+ // prod → verifyLicenseToken falls back to the baked key. This is NOT a payment
185
+ // bypass: forging an identity here only picks WHO the email is; whether they're
186
+ // paying is still the live remote billing-api (fetchIsPaying). An env escape
187
+ // hatch already exists on the gate (MANAGER_ENTITLEMENT_BYPASS=1) — same spirit.
188
+ const LICENSE_PUBKEY_OVERRIDE = (() => {
189
+ const raw = process.env.MANAGER_LICENSE_PUBKEY;
190
+ if (!raw) return undefined;
191
+ try { return JSON.parse(raw); } catch { return undefined; }
192
+ })();
180
193
 
181
194
  // Analytics identity (A+): once signed in, scope usage events to the ACCOUNT
182
195
  // (the billing Worker hashes this email → the same account_id the accounts table
@@ -210,7 +223,7 @@ async function loadLicenseFromDisk() {
210
223
  if (!existsSync(licenseFile)) return;
211
224
  const token = readFileSync(licenseFile, 'utf8').trim();
212
225
  if (!token) return;
213
- const result = await verifyLicenseToken({ token });
226
+ const result = await verifyLicenseToken({ token, publicJwk: LICENSE_PUBKEY_OVERRIDE });
214
227
  if (result.valid) licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
215
228
  }
216
229
  loadLicenseFromDisk();
@@ -219,7 +232,7 @@ loadLicenseFromDisk();
219
232
  // token to disk that doesn't actually verify, so a bad paste can't silently
220
233
  // wedge the app into "licensed but broken".
221
234
  async function activateLicense(token) {
222
- const result = await verifyLicenseToken({ token: String(token ?? '') });
235
+ const result = await verifyLicenseToken({ token: String(token ?? ''), publicJwk: LICENSE_PUBKEY_OVERRIDE });
223
236
  if (!result.valid) return { ok: false, error: `invalid license: ${result.reason}` };
224
237
  writeFileSync(licenseFile, String(token));
225
238
  licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
@@ -250,26 +263,32 @@ function signinResultPage(outcome) {
250
263
  `</script></body>`;
251
264
  }
252
265
 
253
- // "Currently occupying capacity" for the free-tier pending-goal cap every
254
- // status short of a genuinely finished/dismissed one. Deliberately broad for
255
- // the same reason RATE_LIMIT_RE above is broad: undercounting here would let
256
- // someone quietly exceed the cap via an odd status, which costs more than
257
- // occasionally counting a goal that's nearly done.
258
- const OPEN_GOAL_STATUSES = ['stacked', 'sending', 'pending', 'planning', 'needsInput', 'running', 'partial', 'failed', 'interrupted', 'blocked', 'review', 'retesting'];
259
-
260
- // NOTE(billing, needs a product decision): `projectCount` is left at 0 —
261
- // projects are shared/global in this codebase today (see the "Projects ...
262
- // stay shared for everyone" comment above goalVisibleTo in lib.mjs), not
263
- // per-identity, so a per-user "max 1 project" cap has nothing correct to
264
- // gate yet (an unpaid user's 2nd project would affect every other user, not
265
- // just them). checkFreeTierLimit already supports projectCount > 1 the
266
- // moment that's resolved see docs/BILLING-LAUNCH-PLAN.md.
266
+ // The 5-slot "queued to-dos" cap counts QUEUED_GOAL_STATUSES (from lib.mjs)
267
+ // every open status EXCEPT `review`. A goal in review has finished its work and
268
+ // is waiting on a human, so it isn't a to-do in flight, and galda.app sells the
269
+ // slot as "queued to-dos" (Masa 2026-07-17, "5枠は実行待ちだけ"). Still broad
270
+ // otherwise, for the same reason RATE_LIMIT_RE is: undercounting would let
271
+ // someone quietly exceed the cap via an odd status. The list lives in lib.mjs
272
+ // so the wall's meaning is defined next to checkFreeTierLimit and unit-tested.
273
+
274
+ // `projectCount` is install-wide, not per-identity: projects are shared/global
275
+ // in this codebase (see the "Projects ... stay shared for everyone" comment
276
+ // above goalVisibleTo in lib.mjs) while only goals are partitioned by owner.
277
+ // That asymmetry used to leave this at 0 pending a product decision (a per-user
278
+ // "max 1 project" cap would have let one user's 2nd project block everyone
279
+ // else's). Resolved 2026-07-17 (Masa): the shipping product is local-first —
280
+ // app.galda.app reaches each user's OWN engine over the relay, so one install
281
+ // is one user, and the license this gate consults (licenseState.email) is
282
+ // already install-wide too. Counting every project on the install is therefore
283
+ // the same scope billing already runs at. The stale caveat only applies to the
284
+ // legacy Cloudflare Access multi-user mode, where several identities share one
285
+ // install AND one license — there, a shared cap is already the status quo.
267
286
  function computeFreeTierUsage(identity) {
268
287
  const owned = goals.filter((g) => (g.owner ?? 'owner') === identity);
269
288
  return {
270
- pendingCount: owned.filter((g) => OPEN_GOAL_STATUSES.includes(g.status)).length,
289
+ pendingCount: owned.filter((g) => QUEUED_GOAL_STATUSES.includes(g.status)).length,
271
290
  cumulativeCount: owned.length,
272
- projectCount: 0,
291
+ projectCount: projects.length,
273
292
  };
274
293
  }
275
294
 
@@ -362,15 +381,25 @@ async function getBillingPrice() {
362
381
  // every installation (~/.claude/plans/toasty-rolling-marshmallow.md).
363
382
  // A personal/dev instance that wants to skip billing entirely should opt in
364
383
  // explicitly via MANAGER_ENTITLEMENT_BYPASS=1, not ride the 'owner' string.
365
- async function requireEntitlementGate(identity) {
384
+ async function requireEntitlementGate(identity, intent = 'goal') {
366
385
  if (process.env.MANAGER_ENTITLEMENT_BYPASS === '1') return { allowed: true, blocked: null };
367
- // Count the goal being created (existing + 1): computeFreeTierUsage counts
368
- // only goals already on file and this gate runs *before* the new one is
386
+ // Count the thing being created (existing + 1): computeFreeTierUsage counts
387
+ // only what's already on file and this gate runs *before* the new one is
369
388
  // added, so "5 pending / 19 lifetime max" must block the 6th / 20th create,
370
389
  // not the 7th / 21st. (checkFreeTierLimit keeps its `> limit` semantics; we
371
390
  // feed it the post-create count. Display in /api/state stays the raw count.)
391
+ //
392
+ // Only the dimension the caller is actually spending gets its real count;
393
+ // the others are zeroed. checkFreeTierLimit weighs all three at once and
394
+ // mirrors billing-api byte-for-byte, so it must not learn about intents —
395
+ // the caller narrows the question instead. Without this, an install already
396
+ // holding 2 projects (created before the project cap was enforced) would
397
+ // trip 'project-limit' on every *goal* create and lose a feature it had:
398
+ // caps are enforced at the moment you spend, never retroactively.
372
399
  const existing = computeFreeTierUsage(identity);
373
- const usage = { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1 };
400
+ const usage = intent === 'project'
401
+ ? { pendingCount: 0, cumulativeCount: 0, projectCount: existing.projectCount + 1 }
402
+ : { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1, projectCount: 0 };
374
403
  if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
375
404
  const billingEmail = licenseState.email;
376
405
  if (!billingEmail) { const r = resolveEntitlement({ isOwner: false, isPaying: false, usage }); noteFreeExhausted(r); return r; }
@@ -2478,7 +2507,55 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2478
2507
  // network needed) — this goal's changed files are already final at this
2479
2508
  // point (all its tasks are done), so its conflictsWith is accurate even if
2480
2509
  // PR creation itself fails (e.g. no `gh` auth) or is still no-op'd (no code).
2481
- updateGoalConflicts(goal);
2510
+ const conflicts = updateGoalConflicts(goal);
2511
+ // PRD §6.5 — the review gate. conflictsWith above only DESCRIBES the
2512
+ // collision; this acts on it. A goal that landed on the same paths as one
2513
+ // already awaiting review must not open a SECOND PR: both would edit the same
2514
+ // lines and fight when they land, which is exactly the "10 goals, 10
2515
+ // conflicting PRs" morning §6.5 exists to kill. Same-place work goes onto the
2516
+ // same PR, so what sits in Review never conflicts with itself.
2517
+ //
2518
+ // This is the last of the three gates (queue / doing / review) and the only
2519
+ // one that carries the promise: the files compared here are measured, not
2520
+ // predicted. That is what lets the earlier, cheaper gates guess — a wrong
2521
+ // guess costs the re-run below, never a conflict.
2522
+ //
2523
+ // We re-run this goal's intent inside the target's worktree rather than
2524
+ // replaying its diff, because openPR COPIES changed files into the PR
2525
+ // worktree instead of merging them: aiming this goal at the target's branch
2526
+ // would silently overwrite the target's version of any shared file. Re-running
2527
+ // lets the worker SEE the target's changes and build on them, so there is
2528
+ // never a conflict to resolve. It costs one extra worker pass — the price of
2529
+ // gates 1/2 having missed, paid in tokens instead of in the user's morning.
2530
+ //
2531
+ // Same owner only: folding across owners would splice one user's work onto
2532
+ // another's PR and echo its text back — the same leak the intake-side fold
2533
+ // guards against with a visibility check.
2534
+ const foldTarget = conflicts.length
2535
+ ? goals.find((g) => g.id === pickFoldTarget(
2536
+ { id: goal.id, changedFiles: goalChangedFileUnion(goal.id) },
2537
+ goals
2538
+ .filter((c) => c.id !== goal.id
2539
+ && c.projectId === goal.projectId
2540
+ && c.status === 'review'
2541
+ && (c.owner ?? 'owner') === (goal.owner ?? 'owner'))
2542
+ .map((c) => ({ id: c.id, changedFiles: goalChangedFileUnion(c.id) })),
2543
+ ))
2544
+ : null;
2545
+ if (foldTarget) {
2546
+ goalAct(`Same files as goal ${foldTarget.id} — folding onto its PR instead of opening a second one.`);
2547
+ // The target re-opens: it has new work to do, and createGoalPR's rework path
2548
+ // will push the result onto the PR branch it already owns.
2549
+ foldTarget.status = 'running';
2550
+ queueReplyTask(foldTarget, goal.text.slice(0, 8000));
2551
+ saveGoal(foldTarget);
2552
+ // The folded goal keeps its row (PRD §3.5: a submission is never dropped) —
2553
+ // it becomes a pointer at the goal that now carries its work.
2554
+ goal.status = 'folded';
2555
+ goal.foldedInto = foldTarget.id;
2556
+ saveGoal(goal);
2557
+ return;
2558
+ }
2482
2559
  // Computed BEFORE createGoalPR (not inside verifyGate, which used to run
2483
2560
  // after) so the PR body's "確認ポイント" headline is available the first
2484
2561
  // time the PR is opened, not only on the review card (Masa 2026-07-16).
@@ -2979,9 +3056,15 @@ const server = createServer(async (req, res) => {
2979
3056
  if (url.pathname === '/api/projects' && req.method === 'POST') {
2980
3057
  let body = '';
2981
3058
  req.on('data', (d) => { body += d; });
2982
- req.on('end', () => {
3059
+ req.on('end', async () => {
2983
3060
  try {
2984
3061
  const input = JSON.parse(body || '{}');
3062
+ // Billing (docs/BILLING-LAUNCH-PLAN.md §2/§5): a 2nd project is paid.
3063
+ // Same 402 shape as POST /api/tasks so the client has one branch to
3064
+ // handle. Gated here, on create, and nowhere else: projects that
3065
+ // already exist keep working on the free tier.
3066
+ const gate = await requireEntitlementGate(identityFor(req), 'project');
3067
+ if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
2985
3068
  const base = projects.find((p) => p.id === input.baseProjectId) ?? projects[0];
2986
3069
  const nameBase = String(input.name ?? 'New project').trim().slice(0, 80) || 'New project';
2987
3070
  let name = nameBase, n = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.24",
3
+ "version": "0.10.30",
4
4
  "type": "module",
5
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": {