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.
- package/client/dist/assets/{ActivityFeedPage-Cq4UlyrP.js → ActivityFeedPage-D0HbUDeS.js} +1 -1
- package/client/dist/assets/AgentBrowserCapture-Brth-3zu.js +1 -0
- package/client/dist/assets/{AgentModeCodePane-W7XIIcKj.js → AgentModeCodePane-UuKgXzPX.js} +2 -2
- package/client/dist/assets/AgentModeJobsPane-_P5Xm6ij.js +1 -0
- package/client/dist/assets/AgentsPage-BHpan6kZ.js +86 -0
- package/client/dist/assets/{AnalyticsPage-27FMsav_.js → AnalyticsPage-kryIHuZy.js} +1 -1
- package/client/dist/assets/{BarChart-BiFF8sw8.js → BarChart-C5W41oN4.js} +1 -1
- package/client/dist/assets/CodePage-B0WhKBeS.js +2 -0
- package/client/dist/assets/DesktopAnalyticsPage-u9a30yar.js +1 -0
- package/client/dist/assets/{DocsDialog-DG_c7_6k.js → DocsDialog-GSzYhxfg.js} +2 -2
- package/client/dist/assets/{DocsPage-CDieUvMv.js → DocsPage-Bm0J873n.js} +2 -2
- package/client/dist/assets/{ExportDropdown-Cokk4zlC.js → ExportDropdown-CXc3MBkq.js} +1 -1
- package/client/dist/assets/IntegrationsPage-Z5Qy3n6G.js +3 -0
- package/client/dist/assets/{InteractiveJobComposer-C2w9yVm8.js → InteractiveJobComposer-BbBSAQm1.js} +6 -6
- package/client/dist/assets/JobDetailModal-CdPgkHdb.js +1 -0
- package/client/dist/assets/{JobDetailPage-BqdQ1wOs.js → JobDetailPage-CprNUYlt.js} +1 -1
- package/client/dist/assets/JobsPage-ClIaG5ow.js +1 -0
- package/client/dist/assets/{LoopBuilderPage-B8QhQ4FV.js → LoopBuilderPage-rWwSzVr_.js} +2 -2
- package/client/dist/assets/{LoopPreviewModal-CK58XIQ_.js → LoopPreviewModal-B0VUdegW.js} +1 -1
- package/client/dist/assets/LoopsPage-Brrl03uv.js +1 -0
- package/client/dist/assets/ProjectSettingsDialog-i1aAs49_.js +1 -0
- package/client/dist/assets/{TemplatePreviewModal-Rtc2P5v3.js → TemplatePreviewModal-C92-Ygm2.js} +1 -1
- package/client/dist/assets/{agent-BrmiC8zZ.js → agent-2gmrdgKk.js} +1 -1
- package/client/dist/assets/{agent-Bpm3uEw7.js → agent-Bq74VvOq.js} +1 -1
- package/client/dist/assets/{agent-CZ7HdyTA.js → agent-BrhvIxZF.js} +1 -1
- package/client/dist/assets/agent-CbHTqhBt.js +1 -0
- package/client/dist/assets/{agent-CBOOT08j2.js → agent-Ch_OIjXA2.js} +1 -1
- package/client/dist/assets/{agent-A0LpODvj2.js → agent-E-X9b1uz2.js} +1 -1
- package/client/dist/assets/{agent-DPGlEboi2.js → agent-i5VEzNfj2.js} +1 -1
- package/client/dist/assets/{agent-BmmZdeg7.js → agent-zI95bSiF.js} +1 -1
- package/client/dist/assets/{brain-DDEGUEos.js → brain-CJ92kxwM.js} +1 -1
- package/client/dist/assets/{dist-js-BP83ZCIg.js → dist-js-BVonQLiD.js} +1 -1
- package/client/dist/assets/{dist-js-Qk8DW7W3.js → dist-js-CAU8gTUN.js} +1 -1
- package/client/dist/assets/index-B9483NYu.css +2 -0
- package/client/dist/assets/{index-BQbiBIg_.js → index-CfHBmcjb.js} +49 -49
- package/client/dist/assets/{lib-CtyGolSR.js → lib-DJy6HBRz.js} +1 -1
- package/client/dist/assets/{upload-BOOCdPin.js → upload-D_1UxNS_.js} +1 -1
- package/client/dist/assets/{value-BHbZ4o6V.js → value-BH0Vlt96.js} +1 -1
- package/client/dist/index.html +3 -3
- package/docs/internals/companion-rails-as-loops-contract.md +1 -1
- package/package.json +1 -1
- package/server/dist/agent-operator-prompt.js +52 -15
- package/server/dist/claude-trust.js +150 -0
- package/server/dist/git-diagnostics.js +78 -0
- package/server/dist/loop-command-catalog.js +13 -7
- package/server/dist/loop-decider.js +6 -4
- package/server/dist/loop-executors.js +34 -0
- package/server/dist/loop-run-manager.js +52 -3
- package/server/dist/mcp/tools/catalog.js +2 -0
- package/server/dist/mcp/tools/git.js +58 -0
- package/server/dist/mcp/tools/rails.js +25 -2
- package/server/dist/project-registry.js +6 -1
- package/server/dist/project-router-git.js +19 -0
- package/server/dist/queue-manager.js +14 -0
- package/server/dist/workspace-manager.js +101 -10
- package/client/dist/assets/AgentBrowserCapture-BqGtoL0L.js +0 -1
- package/client/dist/assets/AgentModeJobsPane-CryUvMB9.js +0 -1
- package/client/dist/assets/AgentsPage-Re0K2JCa.js +0 -86
- package/client/dist/assets/CodePage-Ud12s91j.js +0 -2
- package/client/dist/assets/DesktopAnalyticsPage-BdjWlYgU.js +0 -1
- package/client/dist/assets/IntegrationsPage-oyvHxjdV.js +0 -3
- package/client/dist/assets/JobDetailModal-DEStK2P4.js +0 -1
- package/client/dist/assets/JobsPage-BH5pl9o_.js +0 -1
- package/client/dist/assets/LoopsPage-C0aVmXxN.js +0 -1
- package/client/dist/assets/ProjectSettingsDialog-Bf_x-MSF.js +0 -1
- package/client/dist/assets/agent-BCi1xJEH.js +0 -1
- 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
|
|
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
|
|
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
|
-
'
|
|
87
|
-
'-
|
|
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
|
|
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 =
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
214
|
-
hint: `Launch accepted (202) on
|
|
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
|
-
|
|
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;
|