@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/CLAUDE.md +12 -1
- package/app/index.html +625 -92
- package/engine/lib.mjs +57 -3
- package/engine/server.mjs +108 -25
- package/package.json +1 -1
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
|
|
2739
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
254
|
-
// status
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// projects are shared/global
|
|
262
|
-
// stay shared for everyone" comment
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
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) =>
|
|
289
|
+
pendingCount: owned.filter((g) => QUEUED_GOAL_STATUSES.includes(g.status)).length,
|
|
271
290
|
cumulativeCount: owned.length,
|
|
272
|
-
projectCount:
|
|
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
|
|
368
|
-
// only
|
|
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 =
|
|
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