@galda/cli 0.10.94 → 0.10.95

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.
Files changed (2) hide show
  1. package/engine/server.mjs +80 -5
  2. package/package.json +1 -1
package/engine/server.mjs CHANGED
@@ -1425,8 +1425,19 @@ function gitStatusPaths(lines = []) {
1425
1425
  function newAttemptId(goal, task) {
1426
1426
  return `goal-${goal?.id ?? 'none'}-task-${task.id}-${Date.now()}-${randomBytes(3).toString('hex')}`;
1427
1427
  }
1428
- function noteGoalTaskBaseline(goal, project, workDir, before) {
1429
- if (!goal || goal.prBaseline) return;
1428
+ // `refresh` re-measures a baseline that already exists. Without it the snapshot
1429
+ // is write-once: whatever the shared checkout happened to look like when the
1430
+ // goal's FIRST task started is what classifyPrSafety judges forever. So a goal
1431
+ // that began while some unrelated file was dirty is permanently unable to
1432
+ // produce a PR — "PR creation skipped: this goal started in a dirty shared
1433
+ // workspace" re-emits on every close-out even after the workspace was cleaned
1434
+ // minutes later, and no amount of asking can fix it. Measured on the operator's
1435
+ // instance 2026-07-25..29: goals #729 #733 #731 are all stuck this way.
1436
+ // Re-measuring is only correct while there is no PR yet — once a branch exists
1437
+ // it is the reference, and moving the baseline under it would compare the work
1438
+ // against the wrong starting point.
1439
+ function noteGoalTaskBaseline(goal, project, workDir, before, { refresh = false } = {}) {
1440
+ if (!goal || (goal.prBaseline && !refresh)) return;
1430
1441
  const repoRoot = gitSyncOrNull(workDir, ['rev-parse', '--show-toplevel']);
1431
1442
  const branch = repoRoot ? gitSyncOrNull(workDir, ['branch', '--show-current']) : null;
1432
1443
  const head = repoRoot ? gitSyncOrNull(workDir, ['rev-parse', 'HEAD']) : null;
@@ -2731,7 +2742,9 @@ async function runTask(task) {
2731
2742
  task.attemptId = task.attemptId || newAttemptId(goal, task);
2732
2743
  if (goal) {
2733
2744
  goal.latestAttemptId = task.attemptId;
2734
- noteGoalTaskBaseline(goal, project, workDir, before);
2745
+ // A reply is a fresh attempt: re-measure the workspace so a baseline taken
2746
+ // when the checkout happened to be dirty cannot outlive the mess itself.
2747
+ noteGoalTaskBaseline(goal, project, workDir, before, { refresh: !!task.reply && !goal.pr });
2735
2748
  }
2736
2749
  sendProcessAct(task, `Checked starting git state (${before.length} changed file${before.length === 1 ? '' : 's'} before this task).`);
2737
2750
  // Plan mode makes no edits, so there is nothing to verify — never author a
@@ -3886,7 +3899,15 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
3886
3899
  if (!shouldCreateGoalPR(goal, files)) {
3887
3900
  goal.pr = null;
3888
3901
  goal.prBranch = null;
3889
- goal.prError = null;
3902
+ // Two different situations used to leave the same silent hole. "No PR was
3903
+ // asked for" is nothing to explain — clear the error. But "a PR WAS asked
3904
+ // for and none exists" is a fact the person needs, and nulling prError here
3905
+ // erased the only field the Review card renders a reason from, so the card
3906
+ // showed a PR-less goal with no explanation at all (goals #725 #751 on the
3907
+ // operator's instance: status done, wantsPR true, pr null, prError empty).
3908
+ goal.prError = goal.wantsPR === true
3909
+ ? 'PRを作成できませんでした: 変更されたファイルが1つもありません(実装が行われていない可能性があります)/ Cannot open a PR: this run produced no file changes.'
3910
+ : null;
3890
3911
  saveGoal(goal);
3891
3912
  goalAct(goal.wantsPR === false
3892
3913
  ? 'Deliverable is No PR, so PR creation is skipped even though files changed.'
@@ -5313,6 +5334,20 @@ const server = createServer(async (req, res) => {
5313
5334
  goal.blocked = null;
5314
5335
  saveGoal(goal);
5315
5336
  }
5337
+ // "PR出して" has to count here too. This is the route EVERY follow-up reply
5338
+ // takes (the review card switches to it once a card is in conversation) and
5339
+ // the one MCP manager_reply uses, so until now a PR asked for on the second
5340
+ // reply — or from an agent session — could never take effect: the deliverable
5341
+ // was only ever upgraded in /dismiss. Measured on the operator's instance
5342
+ // 2026-07-25..29: 「あれ、prいるよね?」then「PR作成して」(#790) and
5343
+ // 「問題ないのでpr出して!」then「あれ、問題ないのでpr作ってください」(#800) —
5344
+ // both asked twice and got no PR either time.
5345
+ // Upgrade only: this can turn the deliverable on, never off, so a later
5346
+ // unrelated reply cannot silently withdraw a PR the person already asked for.
5347
+ if (resolveWantsPR('auto', text, false) && goal.wantsPR !== true) {
5348
+ goal.wantsPR = true;
5349
+ saveGoal(goal);
5350
+ }
5316
5351
  json(res, 200, queueReplyTask(goal, text.trim(), { from: 'you', via: 'thread' }));
5317
5352
  } catch { json(res, 400, { error: 'bad json' }); }
5318
5353
  });
@@ -5391,6 +5426,41 @@ const server = createServer(async (req, res) => {
5391
5426
  return json(res, 200, goal);
5392
5427
  }
5393
5428
 
5429
+ // PR再試行 / Retry PR. The Review card has offered this button all along and it
5430
+ // has never had a route to call — fsRetryPrGoal POSTs here, the server had no
5431
+ // match for it, and the press 404'd and showed an error. So the one repair the
5432
+ // failure message advertises ("commit/stash/revert those unrelated changes,
5433
+ // then rerun") was unreachable from the product: the person cleaned the
5434
+ // workspace and had no way to say "now try again".
5435
+ //
5436
+ // Deliberately narrow — this only re-runs PR creation. It starts no worker and
5437
+ // changes no code, so it is safe to press repeatedly: either the PR appears or
5438
+ // the same honest reason comes back.
5439
+ const retryPrMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/retry-pr$/);
5440
+ if (retryPrMatch && req.method === 'POST') {
5441
+ const goal = goals.find((g) => g.id === Number(retryPrMatch[1]));
5442
+ if (!goal) return json(res, 404, { error: 'goal not found' });
5443
+ if (!requireGoalOwnership(req, res, goal)) return;
5444
+ if (goal.pr) return json(res, 409, { error: 'this goal already has a pull request' });
5445
+ const project = projects.find((p) => p.id === goal.projectId);
5446
+ if (!project) return json(res, 404, { error: 'project not found' });
5447
+ // Asking to retry the PR IS asking for a PR — a goal that was created as
5448
+ // No PR but whose owner now presses this wants one.
5449
+ goal.wantsPR = true;
5450
+ // The frozen dirty-workspace verdict is exactly what this button exists to
5451
+ // clear. Re-measure against the workspace as it is NOW, which is the state
5452
+ // the person just repaired.
5453
+ const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
5454
+ goal.prBaseline = null;
5455
+ goal.prSafety = null;
5456
+ goal.prError = null;
5457
+ noteGoalTaskBaseline(goal, project, wdir, await gitChanges(wdir));
5458
+ const siblings = tasks.filter((t) => t.goalId === goal.id);
5459
+ await createGoalPR(goal, { ...project, dir: wdir }, siblings, getReviewDefinition(goal.projectId), null);
5460
+ saveGoal(goal);
5461
+ return json(res, 200, { goal, pr: goal.pr ?? null, prError: goal.prError ?? null });
5462
+ }
5463
+
5394
5464
  // Archive: retire an UNFINISHED goal (partial/failed/interrupted/blocked)
5395
5465
  // cleanly — spawning no rework. DELETE only accepts stacked/pending, and
5396
5466
  // dismiss on a partial goal queues a "差し戻し" rework task, so a goal that
@@ -5678,7 +5748,12 @@ async function answerGoalQuestion(goal, question) {
5678
5748
  // Manager close-out below owns commit/push/PR creation. Keep this
5679
5749
  // monotonic: an explicit later PR request upgrades a No-PR/auto goal,
5680
5750
  // while ordinary feedback never silently downgrades an existing PR goal.
5681
- if (result.spawnsWorker && resolveWantsPR('auto', text, false)) goal.wantsPR = true;
5751
+ // Not gated on spawnsWorker: what the person wants DELIVERED is independent
5752
+ // of where their words were routed. A PR asked for while parking the goal
5753
+ // (rescope) or while asking a question (answer) is still a PR they asked
5754
+ // for, and it must survive until close-out rather than being dropped
5755
+ // because this particular reply happened not to wake a worker.
5756
+ if (resolveWantsPR('auto', text, false)) goal.wantsPR = true;
5682
5757
  goal.status = result.status;
5683
5758
  saveGoal(goal);
5684
5759
  // rescope はワーカーを起こさない——「いったん止めて考え直す」なので、走り出したら
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.94",
3
+ "version": "0.10.95",
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": {