specrails-desktop 2.23.0 → 2.24.0

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 (67) hide show
  1. package/client/dist/assets/{ActivityFeedPage-Cq4UlyrP.js → ActivityFeedPage-D0HbUDeS.js} +1 -1
  2. package/client/dist/assets/AgentBrowserCapture-Brth-3zu.js +1 -0
  3. package/client/dist/assets/{AgentModeCodePane-W7XIIcKj.js → AgentModeCodePane-UuKgXzPX.js} +2 -2
  4. package/client/dist/assets/AgentModeJobsPane-_P5Xm6ij.js +1 -0
  5. package/client/dist/assets/AgentsPage-BHpan6kZ.js +86 -0
  6. package/client/dist/assets/{AnalyticsPage-27FMsav_.js → AnalyticsPage-kryIHuZy.js} +1 -1
  7. package/client/dist/assets/{BarChart-BiFF8sw8.js → BarChart-C5W41oN4.js} +1 -1
  8. package/client/dist/assets/CodePage-B0WhKBeS.js +2 -0
  9. package/client/dist/assets/DesktopAnalyticsPage-u9a30yar.js +1 -0
  10. package/client/dist/assets/{DocsDialog-DG_c7_6k.js → DocsDialog-GSzYhxfg.js} +2 -2
  11. package/client/dist/assets/{DocsPage-CDieUvMv.js → DocsPage-Bm0J873n.js} +2 -2
  12. package/client/dist/assets/{ExportDropdown-Cokk4zlC.js → ExportDropdown-CXc3MBkq.js} +1 -1
  13. package/client/dist/assets/IntegrationsPage-Z5Qy3n6G.js +3 -0
  14. package/client/dist/assets/{InteractiveJobComposer-C2w9yVm8.js → InteractiveJobComposer-BbBSAQm1.js} +6 -6
  15. package/client/dist/assets/JobDetailModal-CdPgkHdb.js +1 -0
  16. package/client/dist/assets/{JobDetailPage-BqdQ1wOs.js → JobDetailPage-CprNUYlt.js} +1 -1
  17. package/client/dist/assets/JobsPage-ClIaG5ow.js +1 -0
  18. package/client/dist/assets/{LoopBuilderPage-B8QhQ4FV.js → LoopBuilderPage-rWwSzVr_.js} +2 -2
  19. package/client/dist/assets/{LoopPreviewModal-CK58XIQ_.js → LoopPreviewModal-B0VUdegW.js} +1 -1
  20. package/client/dist/assets/LoopsPage-Brrl03uv.js +1 -0
  21. package/client/dist/assets/ProjectSettingsDialog-i1aAs49_.js +1 -0
  22. package/client/dist/assets/{TemplatePreviewModal-Rtc2P5v3.js → TemplatePreviewModal-C92-Ygm2.js} +1 -1
  23. package/client/dist/assets/{agent-BrmiC8zZ.js → agent-2gmrdgKk.js} +1 -1
  24. package/client/dist/assets/{agent-Bpm3uEw7.js → agent-Bq74VvOq.js} +1 -1
  25. package/client/dist/assets/{agent-CZ7HdyTA.js → agent-BrhvIxZF.js} +1 -1
  26. package/client/dist/assets/agent-CbHTqhBt.js +1 -0
  27. package/client/dist/assets/{agent-CBOOT08j2.js → agent-Ch_OIjXA2.js} +1 -1
  28. package/client/dist/assets/{agent-A0LpODvj2.js → agent-E-X9b1uz2.js} +1 -1
  29. package/client/dist/assets/{agent-DPGlEboi2.js → agent-i5VEzNfj2.js} +1 -1
  30. package/client/dist/assets/{agent-BmmZdeg7.js → agent-zI95bSiF.js} +1 -1
  31. package/client/dist/assets/{brain-DDEGUEos.js → brain-CJ92kxwM.js} +1 -1
  32. package/client/dist/assets/{dist-js-BP83ZCIg.js → dist-js-BVonQLiD.js} +1 -1
  33. package/client/dist/assets/{dist-js-Qk8DW7W3.js → dist-js-CAU8gTUN.js} +1 -1
  34. package/client/dist/assets/index-B9483NYu.css +2 -0
  35. package/client/dist/assets/{index-BQbiBIg_.js → index-CfHBmcjb.js} +49 -49
  36. package/client/dist/assets/{lib-CtyGolSR.js → lib-DJy6HBRz.js} +1 -1
  37. package/client/dist/assets/{upload-BOOCdPin.js → upload-D_1UxNS_.js} +1 -1
  38. package/client/dist/assets/{value-BHbZ4o6V.js → value-BH0Vlt96.js} +1 -1
  39. package/client/dist/index.html +3 -3
  40. package/docs/internals/companion-rails-as-loops-contract.md +1 -1
  41. package/package.json +1 -1
  42. package/server/dist/agent-operator-prompt.js +52 -15
  43. package/server/dist/claude-trust.js +150 -0
  44. package/server/dist/git-diagnostics.js +78 -0
  45. package/server/dist/loop-command-catalog.js +13 -7
  46. package/server/dist/loop-decider.js +6 -4
  47. package/server/dist/loop-executors.js +34 -0
  48. package/server/dist/loop-run-manager.js +52 -3
  49. package/server/dist/mcp/tools/catalog.js +2 -0
  50. package/server/dist/mcp/tools/git.js +58 -0
  51. package/server/dist/mcp/tools/rails.js +25 -2
  52. package/server/dist/project-registry.js +6 -1
  53. package/server/dist/project-router-git.js +19 -0
  54. package/server/dist/queue-manager.js +14 -0
  55. package/server/dist/workspace-manager.js +101 -10
  56. package/client/dist/assets/AgentBrowserCapture-BqGtoL0L.js +0 -1
  57. package/client/dist/assets/AgentModeJobsPane-CryUvMB9.js +0 -1
  58. package/client/dist/assets/AgentsPage-Re0K2JCa.js +0 -86
  59. package/client/dist/assets/CodePage-Ud12s91j.js +0 -2
  60. package/client/dist/assets/DesktopAnalyticsPage-BdjWlYgU.js +0 -1
  61. package/client/dist/assets/IntegrationsPage-oyvHxjdV.js +0 -3
  62. package/client/dist/assets/JobDetailModal-DEStK2P4.js +0 -1
  63. package/client/dist/assets/JobsPage-BH5pl9o_.js +0 -1
  64. package/client/dist/assets/LoopsPage-C0aVmXxN.js +0 -1
  65. package/client/dist/assets/ProjectSettingsDialog-Bf_x-MSF.js +0 -1
  66. package/client/dist/assets/agent-BCi1xJEH.js +0 -1
  67. package/client/dist/assets/index-DPX6_xIC.css +0 -2
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GIT_DIAGNOSTIC_ACTIONS = void 0;
4
+ exports.isGitDiagnosticAction = isGitDiagnosticAction;
5
+ exports.runGitDiagnostic = runGitDiagnostic;
6
+ exports.gitDiagnosticHelp = gitDiagnosticHelp;
7
+ const child_process_1 = require("child_process");
8
+ const file_provenance_1 = require("./file-provenance");
9
+ const win_spawn_1 = require("./util/win-spawn");
10
+ /**
11
+ * READ-ONLY git / gh diagnostics for a project's repo — the backing for the
12
+ * `specrails_git` MCP tool. Lets the in-app operator agent answer questions like
13
+ * "is this repo connected to GitHub?", "what's the remote?", "is gh
14
+ * authenticated?", "what changed?" through the app's OWN bundled git/gh, instead
15
+ * of refusing or asking the user to run terminal commands.
16
+ *
17
+ * SAFETY: the command list is a FIXED ALLOWLIST — the caller only picks an
18
+ * action name, never arguments, so no flag/ref/path can be smuggled in. Every
19
+ * command is read-only (no push/commit/checkout/pr-create). git runs with the
20
+ * hardened `GIT_EXEC_ENV` (hostile-repo config stripped, prompts disabled); gh
21
+ * runs with the normal env so it finds the user's `~/.config/gh` auth and the
22
+ * bundled `gh` on PATH.
23
+ */
24
+ exports.GIT_DIAGNOSTIC_ACTIONS = [
25
+ 'remote', 'status', 'log', 'diff', 'branch',
26
+ 'gh_repo', 'gh_auth', 'gh_pr_list', 'gh_pr_view',
27
+ ];
28
+ function isGitDiagnosticAction(v) {
29
+ return typeof v === 'string' && exports.GIT_DIAGNOSTIC_ACTIONS.includes(v);
30
+ }
31
+ const SPECS = {
32
+ remote: { cmd: 'git', args: ['remote', '-v'], help: 'List configured remotes (is the repo linked to GitHub, and where).' },
33
+ status: { cmd: 'git', args: ['status', '--short', '--branch'], help: 'Branch + short working-tree status.' },
34
+ log: { cmd: 'git', args: ['log', '--oneline', '--decorate', '-20'], help: 'The last 20 commits.' },
35
+ diff: { cmd: 'git', args: ['diff', '--stat'], help: 'Uncommitted change summary (diffstat).' },
36
+ branch: { cmd: 'git', args: ['branch', '-vv'], help: 'Local branches with upstream tracking.' },
37
+ gh_repo: { cmd: 'gh', args: ['repo', 'view'], help: 'The GitHub repo this checkout is linked to (fails if none / gh unauth).' },
38
+ gh_auth: { cmd: 'gh', args: ['auth', 'status'], help: 'Whether gh is authenticated, and for which host/account.' },
39
+ gh_pr_list: { cmd: 'gh', args: ['pr', 'list', '--limit', '30'], help: 'Open pull requests for the repo.' },
40
+ gh_pr_view: { cmd: 'gh', args: ['pr', 'view'], help: "The current branch's pull request (if any)." },
41
+ };
42
+ const OUTPUT_CAP = 8_000;
43
+ const defaultExec = (cmd, args, cwd) => new Promise((resolve) => {
44
+ // git: hardened, cwd-pinned env. gh: user env (needs ~/.config/gh auth) with
45
+ // the Windows shell-critical vars guaranteed present.
46
+ const env = cmd === 'gh' ? (0, win_spawn_1.windowsSpawnEnv)() : file_provenance_1.GIT_EXEC_ENV;
47
+ (0, child_process_1.execFile)(cmd, args, { cwd, env, timeout: 15_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
48
+ const code = err && typeof err.code === 'number'
49
+ ? err.code
50
+ : err ? 1 : 0;
51
+ resolve({ code, stdout: String(stdout ?? '').trim(), stderr: String(stderr ?? '').trim() });
52
+ });
53
+ });
54
+ const cap = (s) => (s.length > OUTPUT_CAP ? `${s.slice(0, OUTPUT_CAP)}\n…(truncated)` : s);
55
+ /**
56
+ * Run one allow-listed read-only diagnostic in `repoDir`. Throws only on an
57
+ * UNKNOWN action (defence-in-depth; the route validates too); a non-zero exit
58
+ * (e.g. `gh repo view` with no remote) is a normal result with `ok: false`.
59
+ */
60
+ async function runGitDiagnostic(action, repoDir, exec = defaultExec) {
61
+ if (!isGitDiagnosticAction(action)) {
62
+ throw new Error(`Unknown diagnostic action: ${action}. Allowed: ${exports.GIT_DIAGNOSTIC_ACTIONS.join(', ')}`);
63
+ }
64
+ const spec = SPECS[action];
65
+ const r = await exec(spec.cmd, spec.args, repoDir);
66
+ return {
67
+ action,
68
+ command: `${spec.cmd} ${spec.args.join(' ')}`,
69
+ ok: r.code === 0,
70
+ exitCode: r.code,
71
+ stdout: cap(r.stdout),
72
+ stderr: cap(r.stderr),
73
+ };
74
+ }
75
+ /** The action → human help map, for the MCP tool description / discoverability. */
76
+ function gitDiagnosticHelp() {
77
+ return Object.fromEntries(Object.keys(SPECS).map((a) => [a, SPECS[a].help]));
78
+ }
@@ -78,17 +78,23 @@ exports.LOOP_COMMANDS = [
78
78
  {
79
79
  name: 'fix',
80
80
  label: 'fix',
81
- description: 'Refinement step: the verification reported failures — fix ONLY what is needed to make them pass (smallest change, no re-implementing, no unrelated edits). Verification re-runs after.',
81
+ description: 'Refinement/advance step: the Loop Decider judged the goal not yet met. Read the verification output — if it FAILED, fix the smallest thing; if it PASSED but the feature is incomplete, implement the missing pieces; if genuinely blocked on a human decision, emit LOOP_BLOCKED and stop.',
82
82
  ticketScope: 'per-ticket',
83
+ // NOT a "fix the failures" step: the Decider routes here on ANY not-done
84
+ // verdict — which includes "verification PASSED but the feature isn't
85
+ // implemented yet". Assuming a FAIL boxed the agent in (nothing to fix +
86
+ // "don't re-implement") and spun the loop. This branches on the REAL verify
87
+ // verdict and gives a first-class BLOCKED escape so a human-decision blocker
88
+ // halts the loop instead of cycling. See docs/internals + loop-decider.ts.
83
89
  template: [
84
- 'The verification step above reported failures (see VERIFICATION: FAIL and the output before it).',
90
+ 'The Loop Decider judged the goal NOT yet met. First READ the verification output above and act on what it actually says — do not assume it failed:',
85
91
  '',
86
- 'Fix ONLY what is needed to make the failing tests / type-check / lint / build pass:',
87
- '- Make the smallest change that resolves the reported failures.',
88
- '- Do NOT re-implement the feature from scratch and do NOT touch unrelated code.',
89
- '- If a test is wrong, fix the test; if the code is wrong, fix the code.',
92
+ '- If it reported `VERIFICATION: FAIL`: fix ONLY what is needed to make the failing tests / type-check / lint / build pass — smallest change, no unrelated edits, no re-implementing from scratch.',
93
+ '- If it reported `VERIFICATION: PASS` but the spec/feature is NOT fully implemented yet: implement the missing pieces now. This is expected — the loop runs the main step once, then keeps advancing the feature here until it is complete. Build the smallest coherent next increment.',
90
94
  '',
91
- 'The verification will run again after this step.',
95
+ 'Do NOT invent scope or silently take a big architectural decision to make the loop stop. If real progress is BLOCKED on a decision only a human can make — ambiguous requirements, a missing/undone prerequisite, or introducing a new external dependency (a new database, service, or SDK) — do NOT guess and do NOT keep re-running: end your reply with a single line `LOOP_BLOCKED: <the one specific question the human must answer>` and stop. The loop will halt and surface it instead of cycling.',
96
+ '',
97
+ 'Verification will run again after this step (unless you reported LOOP_BLOCKED).',
92
98
  ].join('\n'),
93
99
  },
94
100
  {
@@ -12,7 +12,7 @@ exports.DECIDER_PROMPT_VERSION = void 0;
12
12
  exports.buildDeciderSystemPrompt = buildDeciderSystemPrompt;
13
13
  exports.buildDeciderUserPrompt = buildDeciderUserPrompt;
14
14
  exports.parseDeciderDecision = parseDeciderDecision;
15
- exports.DECIDER_PROMPT_VERSION = 2;
15
+ exports.DECIDER_PROMPT_VERSION = 3;
16
16
  /** Byte-stable system prompt (no timestamps/aggregates) so prompt caching hits. */
17
17
  function buildDeciderSystemPrompt() {
18
18
  return [
@@ -21,8 +21,9 @@ function buildDeciderSystemPrompt() {
21
21
  'Decide whether the goal is now met (STOP) or another iteration is needed (CONTINUE).',
22
22
  'Be strict: only STOP when the goal is genuinely satisfied by the evidence in the history.',
23
23
  'A step CLAIMING success (e.g. printing "VERIFICATION: PASS") is NOT proof on its own — weigh it against the SPEC. If the spec describes work the history does not yet evidence as done, CONTINUE even if a step reported success.',
24
+ 'BLOCKED is a THIRD option, distinct from continue/stop. Choose "blocked" when the loop is stuck on a decision only a HUMAN can make and further iterations cannot make progress — e.g. the latest step printed a line starting `LOOP_BLOCKED:`, OR a step repeatedly asks the human the same question / reports it will not proceed without a decision, OR the history shows several iterations with no new progress toward the goal. Do NOT pick "continue" in those cases — it just burns iterations. Put the blocking question in `reasoning`.',
24
25
  'Respond with ONLY a single-line JSON object — no prose, no markdown, no code fences:',
25
- '{"action":"continue"|"stop","reasoning":"<one short sentence>"}',
26
+ '{"action":"continue"|"stop"|"blocked","reasoning":"<one short sentence>"}',
26
27
  ].join('\n');
27
28
  }
28
29
  function buildDeciderUserPrompt(input) {
@@ -52,9 +53,10 @@ function parseDeciderDecision(raw) {
52
53
  try {
53
54
  const obj = JSON.parse(matches[i]);
54
55
  const action = typeof obj.action === 'string' ? obj.action.toLowerCase().trim() : '';
55
- if (action === 'stop' || action === 'continue') {
56
+ if (action === 'stop' || action === 'continue' || action === 'blocked') {
56
57
  return {
57
58
  continue: action === 'continue',
59
+ blocked: action === 'blocked',
58
60
  reasoning: typeof obj.reasoning === 'string' ? obj.reasoning : '',
59
61
  parsed: true,
60
62
  };
@@ -65,5 +67,5 @@ function parseDeciderDecision(raw) {
65
67
  }
66
68
  }
67
69
  }
68
- return { continue: true, reasoning: '(could not parse decision; continuing)', parsed: false };
70
+ return { continue: true, blocked: false, reasoning: '(could not parse decision; continuing)', parsed: false };
69
71
  }
@@ -13,9 +13,11 @@ exports.createLoopExecutors = createLoopExecutors;
13
13
  * fake executors in `loop-run-manager.test.ts`.
14
14
  */
15
15
  const node_child_process_1 = require("node:child_process");
16
+ const node_crypto_1 = require("node:crypto");
16
17
  const tree_kill_1 = __importDefault(require("tree-kill"));
17
18
  const providers_1 = require("./providers");
18
19
  const workspace_manager_1 = require("./workspace-manager");
20
+ const claude_trust_1 = require("./claude-trust");
19
21
  const spawn_lifecycle_1 = require("./spawn-lifecycle");
20
22
  const result_event_1 = require("./result-event");
21
23
  const loop_decider_1 = require("./loop-decider");
@@ -118,6 +120,24 @@ function aiStepExtraArgs(adapter, cwd, repoDir) {
118
120
  function createLoopExecutors(opts = {}) {
119
121
  const resolveEnv = () => typeof opts.env === 'function' ? opts.env() : opts.env ?? process.env;
120
122
  return {
123
+ // Cheap working-tree fingerprint for the engine's non-convergence guard:
124
+ // HEAD sha + the porcelain dirty set, hashed. Two identical fingerprints
125
+ // across consecutive Decider 'continue' verdicts ⇒ the loop changed nothing
126
+ // ⇒ abort `stalled`. Synchronous + best-effort: any git failure (no repo,
127
+ // detached, etc.) returns null so the guard simply stays off. Never throws.
128
+ repoStateHash(dir) {
129
+ try {
130
+ const opt = { cwd: dir, encoding: 'utf-8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] };
131
+ const head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], opt).trim();
132
+ // `-z` NUL-delimited + include untracked so a brand-new file counts as
133
+ // a change; stable ordering makes the hash deterministic per tree state.
134
+ const porcelain = (0, node_child_process_1.execFileSync)('git', ['status', '--porcelain', '-z', '--untracked-files=all'], opt);
135
+ return (0, node_crypto_1.createHash)('sha256').update(head).update('\0').update(porcelain).digest('hex');
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ },
121
141
  async runAiStep({ prompt, sessionId, provider, model, effort, cwd, repoDir, onLine, onRawLine, onSpawn, aiStepTimeoutMs }) {
122
142
  const adapter = (0, providers_1.getAdapter)(provider);
123
143
  // First iteration spawns headless (rail-job); subsequent iterations resume
@@ -133,9 +153,16 @@ function createLoopExecutors(opts = {}) {
133
153
  if (repoDir) {
134
154
  try {
135
155
  (0, workspace_manager_1.ensureFrameworkAgents)(cwd, adapter.projectDirName);
156
+ (0, workspace_manager_1.ensureFrameworkCommandSubtrees)(cwd, adapter.projectDirName);
136
157
  }
137
158
  catch { /* best-effort */ }
138
159
  }
160
+ // Pre-trust the spawn dir so headless claude honours the overlaid
161
+ // `.claude/settings.json` permissions.allow (else "workspace not trusted").
162
+ try {
163
+ (0, claude_trust_1.ensureClaudeTrusted)(adapter.id, [cwd, repoDir]);
164
+ }
165
+ catch { /* best-effort */ }
139
166
  let text = '';
140
167
  // The adapter parses provider failures (codex `turn.failed`, etc.) into a
141
168
  // structured `error` event on the stdout stream — capture its message so we
@@ -243,9 +270,16 @@ function createLoopExecutors(opts = {}) {
243
270
  if (repoDir) {
244
271
  try {
245
272
  (0, workspace_manager_1.ensureFrameworkAgents)(cwd, adapter.projectDirName);
273
+ (0, workspace_manager_1.ensureFrameworkCommandSubtrees)(cwd, adapter.projectDirName);
246
274
  }
247
275
  catch { /* best-effort */ }
248
276
  }
277
+ // Pre-trust the spawn dir so headless claude honours the overlaid
278
+ // `.claude/settings.json` permissions.allow (else "workspace not trusted").
279
+ try {
280
+ (0, claude_trust_1.ensureClaudeTrusted)(adapter.id, [cwd, repoDir]);
281
+ }
282
+ catch { /* best-effort */ }
249
283
  const args = adapter.buildArgs('chat-stream', {
250
284
  // chat-stream feeds the prompt over stdin per-turn, so the argv `prompt`
251
285
  // is unused — pass empty to satisfy the shared SpawnOptions shape.
@@ -309,6 +309,15 @@ class LoopRunManager {
309
309
  // Consecutive AI steps that hard-failed with no output (provider down /
310
310
  // out of quota / crashing). Reset on any step that runs or produces output.
311
311
  let consecutiveAiFailures = 0;
312
+ // Non-convergence guard: the working-tree fingerprint at the last Decider
313
+ // verdict, and how many consecutive 'continue' verdicts have left it
314
+ // UNCHANGED. Two zero-change iterations in a row ⇒ the loop is spinning
315
+ // without progress (verify→fix→verify producing an identical tree) → abort
316
+ // `stalled` instead of burning iterations/cost. Disabled when the executor
317
+ // provides no `repoStateHash` or it can't read the tree.
318
+ let lastRepoHash = null;
319
+ let consecutiveNoProgress = 0;
320
+ const NO_PROGRESS_LIMIT = 2;
312
321
  // True for the step that runs immediately after a Decider 'continue' — that
313
322
  // step must see the cross-iteration history (the verdict it acts on). Mid-body
314
323
  // RESUMED steps already carry prior context in their session, so re-appending
@@ -660,10 +669,11 @@ class LoopRunManager {
660
669
  // is truly down the Decider also fails → parsed=false → no reset.)
661
670
  if (dec.parsed)
662
671
  consecutiveAiFailures = 0;
672
+ const verdictWord = dec.blocked ? 'blocked' : dec.continue ? 'continue' : 'stop';
663
673
  // BUG-03: a Decider that couldn't parse a verdict (dec.parsed === false)
664
674
  // is a failed AI invocation — record it as such, not as success.
665
675
  record(`loop:${runId}:decider`, { ...dec, failed: !dec.parsed }, deciderStart);
666
- logLine(`Decision: ${dec.continue ? 'continue' : 'stop'} — ${dec.reasoning}`);
676
+ logLine(`Decision: ${verdictWord} — ${dec.reasoning}`);
667
677
  // The verdict line above is the decider step's last output. `decision`
668
678
  // is the route actually taken (an unparseable verdict defaults to
669
679
  // continue AND flags the step failed — mirrors record()'s status).
@@ -672,7 +682,7 @@ class LoopRunManager {
672
682
  durationMs: dec.durationMs,
673
683
  decision: dec.continue ? 'continue' : 'stop',
674
684
  });
675
- history.push(`Decider: ${dec.continue ? 'continue' : 'stop'} — ${dec.reasoning}`);
685
+ history.push(`Decider: ${verdictWord} — ${dec.reasoning}`);
676
686
  (0, loop_runs_store_1.updateLoopRunCounters)(this.db, runId, {
677
687
  iterationCount: iteration,
678
688
  totalCostUsd: totalCost,
@@ -696,6 +706,40 @@ class LoopRunManager {
696
706
  const branchTarget = (b) => out.find((e) => e.branch === b)?.target;
697
707
  const ends = succs.filter((n) => n.type === 'end');
698
708
  const conts = succs.filter((n) => n.type !== 'end');
709
+ // BLOCKED: the loop is stuck on a human decision — halt now with a
710
+ // dedicated outcome (NOT success) and surface the question. This is
711
+ // the fix for the verify→fix cycle spinning on an unanswered scope
712
+ // question the loop can't resolve on its own.
713
+ if (dec.blocked) {
714
+ logLine(`\n■ Loop blocked — needs a human decision: ${dec.reasoning}`, 'stderr');
715
+ outcome = 'blocked';
716
+ settled = true;
717
+ break;
718
+ }
719
+ // Non-convergence guard: if two consecutive 'continue' iterations
720
+ // leave the working tree byte-identical, the loop isn't progressing
721
+ // (e.g. verify keeps passing a baseline while the feature is never
722
+ // written) — abort `stalled` rather than cycle to the cap.
723
+ if (dec.continue && this.executors.repoStateHash) {
724
+ const dir = req.repoDir ?? req.cwd;
725
+ const hash = this.executors.repoStateHash(dir);
726
+ // null = couldn't read the tree → skip (never let null match null).
727
+ if (hash !== null) {
728
+ if (hash === lastRepoHash) {
729
+ consecutiveNoProgress += 1;
730
+ if (consecutiveNoProgress >= NO_PROGRESS_LIMIT) {
731
+ logLine(`\n■ Loop stalled — ${consecutiveNoProgress + 1} iterations changed nothing in the working tree; stopping instead of cycling.`, 'stderr');
732
+ outcome = 'stalled';
733
+ settled = true;
734
+ break;
735
+ }
736
+ }
737
+ else {
738
+ consecutiveNoProgress = 0;
739
+ }
740
+ lastRepoHash = hash;
741
+ }
742
+ }
699
743
  if (dec.continue) {
700
744
  const target = branchTarget('continue') ?? conts[0]?.id;
701
745
  if (target) {
@@ -759,7 +803,12 @@ class LoopRunManager {
759
803
  // Settle the backing job so the Jobs list + JobDetail reflect the final
760
804
  // status, and emit job.finalized so an open JobDetail re-fetches + stops the
761
805
  // live stream (mirrors QueueManager).
762
- const jobStatus = outcome === 'success' ? 'completed' : outcome === 'stopped' ? 'canceled' : 'failed';
806
+ // `blocked`/`stalled` are controlled halts (not done, not a hard crash) →
807
+ // surface as `canceled`, like a user stop, so tickets revert to todo rather
808
+ // than being marked done/on_review AND the run is never counted as success.
809
+ const jobStatus = outcome === 'success' ? 'completed'
810
+ : outcome === 'stopped' || outcome === 'blocked' || outcome === 'stalled' ? 'canceled'
811
+ : 'failed';
763
812
  // `≥` when any cost-bearing step ended unpriced (timeout/crash) — the figure
764
813
  // is a lower bound, not exact.
765
814
  logLine(`\n■ Loop finished: ${outcome} — ${iteration} iteration${iteration === 1 ? '' : 's'}, ${costUncertain ? '≥ ' : ''}$${totalCost.toFixed(4)}`);
@@ -16,6 +16,7 @@ const loops_1 = require("./loops");
16
16
  const code_1 = require("./code");
17
17
  const setup_1 = require("./setup");
18
18
  const analytics_1 = require("./analytics");
19
+ const git_1 = require("./git");
19
20
  /**
20
21
  * The full MCP tool catalog. Domain-facade tools each expose an `action` enum
21
22
  * and call the app's REST API over loopback (see apiCall). Meta tools receive a
@@ -39,6 +40,7 @@ function buildToolSpecs() {
39
40
  specs.push(...(0, code_1.codeTools)());
40
41
  specs.push(...(0, setup_1.setupTools)());
41
42
  specs.push(...(0, analytics_1.analyticsTools)());
43
+ specs.push(...(0, git_1.gitTools)());
42
44
  // Meta last (needs the full list for search/describe)
43
45
  specs.push(...(0, meta_1.metaTools)(() => specs));
44
46
  return specs;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.gitTools = gitTools;
4
+ const zod_1 = require("zod");
5
+ const types_1 = require("./types");
6
+ const git_diagnostics_1 = require("../../git-diagnostics");
7
+ /**
8
+ * Read-only git / GitHub (gh) diagnostics for a project, run through the app's
9
+ * OWN bundled git/gh against the repo. This is the sanctioned MCP path for the
10
+ * operator agent to inspect version-control state — it does NOT need (and should
11
+ * not use) a raw shell for this. Every action is a FIXED, read-only command; the
12
+ * caller only names an action, never arguments. Repo MUTATIONS (push, pr create,
13
+ * commit) are intentionally NOT here — those belong to the ask-first PR flow
14
+ * (specrails_rails PR decisions), which is auditable and confirmation-gated.
15
+ */
16
+ function gitTools() {
17
+ const help = (0, git_diagnostics_1.gitDiagnosticHelp)();
18
+ const actionLines = git_diagnostics_1.GIT_DIAGNOSTIC_ACTIONS.map((a) => `${a} (${help[a]})`).join('; ');
19
+ return [
20
+ {
21
+ name: 'specrails_git',
22
+ title: 'Git & GitHub diagnostics',
23
+ description: "READ-ONLY git/GitHub diagnostics for a project, via the app's bundled git/gh — use this " +
24
+ "(not a raw shell) to answer questions like \"is this repo connected to GitHub?\", \"what's the " +
25
+ 'remote?\", \"is gh authenticated?\", \"what changed?\", or to inspect PRs. Runs against the ' +
26
+ "project's real repo. All actions are read-only and never prompt. " +
27
+ `Actions: ${actionLines}. ` +
28
+ 'These are NOT for mutating the repo — pushing / creating PRs / committing go through the ' +
29
+ 'ask-first PR flow (specrails_rails PR decisions), never here.',
30
+ hintTier: 'read',
31
+ tier: 'read',
32
+ inputSchema: {
33
+ action: zod_1.z
34
+ .enum(git_diagnostics_1.GIT_DIAGNOSTIC_ACTIONS)
35
+ .describe('Which read-only diagnostic to run'),
36
+ projectId: zod_1.z.string().optional().describe('Project id (defaults to the active project)'),
37
+ },
38
+ async handler(ctx, args) {
39
+ const base = (0, types_1.projectPath)(ctx, args.projectId);
40
+ const action = args.action;
41
+ if (!(0, git_diagnostics_1.isGitDiagnosticAction)(action)) {
42
+ throw new Error(`Unknown action "${action}". Allowed: ${git_diagnostics_1.GIT_DIAGNOSTIC_ACTIONS.join(', ')}.`);
43
+ }
44
+ const qs = new URLSearchParams({ action });
45
+ const r = (await (0, types_1.apiCall)(ctx, 'GET', `${base}/git/diagnostic?${qs.toString()}`));
46
+ // A non-zero exit is a NORMAL, informative outcome (e.g. `gh repo view`
47
+ // when the repo has no GitHub remote) — surface it plainly so the agent
48
+ // reports the real state instead of treating it as an error.
49
+ return {
50
+ ...r,
51
+ hint: r.ok === false
52
+ ? 'The command exited non-zero — this is often meaningful (e.g. no GitHub remote, or gh not authenticated). Read stderr/stdout and report the real state to the user; do not treat it as a tool failure.'
53
+ : undefined,
54
+ };
55
+ },
56
+ },
57
+ ];
58
+ }
@@ -208,10 +208,33 @@ function railsTools() {
208
208
  body.reasoning_effort = defaults.reasoningEffort;
209
209
  }
210
210
  const r = await (0, types_1.apiCall)(ctx, 'POST', `${base}/${railIndex}/launch`, body);
211
+ const railLabel = `Rail ${railIndex + 1}`;
212
+ // Isolation status governs whether a PR-decision card EXISTS. When
213
+ // the router reports `isolationUnavailable`, the run fell back to the
214
+ // shared working tree: NO git worktree, NO PR delivery row, and so
215
+ // NO implementation/PR card will EVER appear (not in this chat, not
216
+ // in the rail header). The agent MUST NOT promise one — it must tell
217
+ // the user the run writes changes DIRECTLY to their files and why.
218
+ const isoReason = typeof r.isolationUnavailable === 'string' ? r.isolationUnavailable : null;
219
+ if (isoReason) {
220
+ const why = isoReason === 'no-git'
221
+ ? 'the project folder is NOT a git repository'
222
+ : isoReason === 'no-commits'
223
+ ? 'the git repository has NO commits yet (an unborn HEAD cannot be branched)'
224
+ : `worktree isolation failed${typeof r.isolationUnavailableDetail === 'string' ? `: ${r.isolationUnavailableDetail}` : ''}`;
225
+ const fix = isoReason === 'error'
226
+ ? 'This is unexpected — report the detail to the user.'
227
+ : 'To get the PR flow, the user should `git init` the folder and make at least one commit (a GitHub remote is NOT required — the review flow then offers "Integrate locally" to accept without GitHub), then relaunch.';
228
+ return {
229
+ ...r,
230
+ railLabel,
231
+ hint: `Launch accepted (202) on ${railLabel}, but WORKTREE ISOLATION IS UNAVAILABLE because ${why}. The run proceeds on the SHARED working tree and writes changes DIRECTLY into the user's files — there is NO PR-decision/implementation card and NO branch. Do NOT tell the user to look for a PR card; tell them the run writes to their files in place, and explain why. ${fix} When it finishes, the spec parks at on_review — the user accepts it by moving it to Done on the board (the changes are already in their files) or reverts the spec's status (which does NOT undo the file changes).`,
232
+ };
233
+ }
211
234
  return {
212
235
  ...r,
213
- railLabel: `Rail ${railIndex + 1}`,
214
- hint: `Launch accepted (202) on railIndex ${railIndex} — tell the user it runs on "Rail ${railIndex + 1}" (UI labels are 1-based). Use specrails_watch with the returned jobId/jobIds (or loopRunIds for loop mode) to await completion. Live output streams over the job WS. Rails run for minutes; pass untilMs up to 600000 and re-watch on timeout.`,
236
+ railLabel,
237
+ hint: `Launch accepted (202) on ${railLabel} (isolated worktree, PR flow active) — tell the user it runs on "${railLabel}" (UI labels are 1-based) and that the PR-decision card will appear here and on the rail header when it settles. Use specrails_watch with the returned loopRunIds to await completion only if asked. Rails run for minutes; pass untilMs up to 600000 and re-watch on timeout.`,
215
238
  };
216
239
  }
217
240
  case 'launch_all': {
@@ -727,7 +727,12 @@ class ProjectRegistry {
727
727
  const ticketIds = meta.ticketIds;
728
728
  if (ticketIds.length === 0)
729
729
  return;
730
- const status = outcome === 'success' ? 'completed' : outcome === 'stopped' ? 'canceled' : 'failed';
730
+ // `blocked`/`stalled` (Decider-flagged human blocker / non-convergence
731
+ // abort) are controlled halts: treat like a user stop → `canceled`, so
732
+ // tickets revert to todo (never done/on_review) and no PR delivery fires.
733
+ const status = outcome === 'success' ? 'completed'
734
+ : outcome === 'stopped' || outcome === 'blocked' || outcome === 'stalled' ? 'canceled'
735
+ : 'failed';
731
736
  // Ask-first PR delivery parks a completed run's tickets at on_review (the
732
737
  // user decides done vs discard via the PR decision flow / a manual move).
733
738
  // Explicit opts (the isolated-rail launch passes its launch-captured
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerGitRoutes = registerGitRoutes;
4
4
  const project_git_1 = require("./project-git");
5
+ const git_diagnostics_1 = require("./git-diagnostics");
5
6
  // ─── Git domain routes (/api/projects/:projectId/git) ─────────────────────────
6
7
  //
7
8
  // Backs the Agent-Mode git bar: current branch + last commit + local branches,
@@ -18,6 +19,24 @@ function registerGitRoutes(deps) {
18
19
  res.status(500).json({ error: 'Failed to read git info' });
19
20
  }
20
21
  });
22
+ // READ-ONLY git/gh diagnostics (backs the specrails_git MCP tool). A fixed
23
+ // allowlist of read-only commands — the query only names an action, never
24
+ // arguments. Git runs against project.path (the real repo; the workspace never
25
+ // holds code under relocation).
26
+ router.get('/:projectId/git/diagnostic', async (req, res) => {
27
+ const action = String(req.query.action ?? '');
28
+ if (!(0, git_diagnostics_1.isGitDiagnosticAction)(action)) {
29
+ res.status(400).json({ error: 'invalid_action', allowed: git_diagnostics_1.GIT_DIAGNOSTIC_ACTIONS });
30
+ return;
31
+ }
32
+ try {
33
+ res.json(await (0, git_diagnostics_1.runGitDiagnostic)(action, ctx(req).project.path));
34
+ }
35
+ catch (err) {
36
+ console.error('[project-git] diagnostic failed:', err);
37
+ res.status(500).json({ error: 'diagnostic_failed', detail: err instanceof Error ? err.message : String(err) });
38
+ }
39
+ });
21
40
  router.post('/:projectId/git/checkout', async (req, res) => {
22
41
  const body = (req.body ?? {});
23
42
  const branch = typeof body.branch === 'string' ? body.branch.trim() : '';
@@ -31,6 +31,7 @@ const attachment_manager_1 = require("./attachment-manager");
31
31
  const ticket_store_1 = require("./ticket-store");
32
32
  const binary_probe_1 = require("./binary-probe");
33
33
  const workspace_manager_1 = require("./workspace-manager");
34
+ const claude_trust_1 = require("./claude-trust");
34
35
  const workspace_resolution_1 = require("./workspace-resolution");
35
36
  const framework_manager_1 = require("./framework-manager");
36
37
  const openspec_shim_1 = require("./openspec-shim");
@@ -1427,11 +1428,24 @@ class QueueManager {
1427
1428
  if (execution.relocated) {
1428
1429
  try {
1429
1430
  (0, workspace_manager_1.ensureFrameworkAgents)(execution.cwd, adapter.projectDirName);
1431
+ // AND the dir-linked subtrees (commands/skills/rules): a broken Windows
1432
+ // `current` junction leaves the workspace with no `/specrails:*` commands
1433
+ // → the CLI reports "Unknown command: /specrails:implement".
1434
+ (0, workspace_manager_1.ensureFrameworkCommandSubtrees)(execution.cwd, adapter.projectDirName);
1430
1435
  }
1431
1436
  catch {
1432
1437
  /* best-effort — never block a rail spawn on the repair */
1433
1438
  }
1434
1439
  }
1440
+ // Pre-trust the spawn dir(s) so headless claude honours the overlaid
1441
+ // `.claude/settings.json` permissions.allow (else it silently drops them:
1442
+ // "this workspace has not been trusted"). Once per unique dir, claude-only.
1443
+ try {
1444
+ (0, claude_trust_1.ensureClaudeTrusted)(adapter.id, [execution.cwd, execution.repoDir]);
1445
+ }
1446
+ catch {
1447
+ /* best-effort */
1448
+ }
1435
1449
  job.status = 'running';
1436
1450
  job.startedAt = new Date().toISOString();
1437
1451
  job.queuePosition = null;