sphinx-understand 0.0.0-stage → 0.1.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/.sphinxconfig.example +10 -0
- package/.sphinxignore.example +22 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +21 -0
- package/README.md +353 -2
- package/bin/sphinx.js +9 -0
- package/docs/architecture.md +196 -0
- package/eval/README.md +38 -0
- package/eval/RESULTS.md +205 -0
- package/eval/fixtures/known-false-negatives.json +115 -0
- package/examples/browser-demo.mjs +28 -0
- package/examples/config.json +59 -0
- package/package.json +50 -4
- package/src/activation-worker.js +10 -0
- package/src/activation.js +187 -0
- package/src/agent-settings.js +206 -0
- package/src/agent.js +45 -0
- package/src/browser-interview.js +278 -0
- package/src/browser-page.js +172 -0
- package/src/check-recovery.js +92 -0
- package/src/checkbox.js +148 -0
- package/src/cli.js +526 -0
- package/src/config.js +196 -0
- package/src/cursor-hook.js +18 -0
- package/src/evaluator.js +475 -0
- package/src/git-gate.js +116 -0
- package/src/git-interview.js +51 -0
- package/src/git.js +252 -0
- package/src/help.js +121 -0
- package/src/ignore.js +94 -0
- package/src/install.js +446 -0
- package/src/lifecycle-integrations.js +259 -0
- package/src/mcp.js +351 -0
- package/src/model-presets.js +79 -0
- package/src/native-check.js +166 -0
- package/src/openrouter.js +239 -0
- package/src/overrides.js +57 -0
- package/src/pi-integration.js +179 -0
- package/src/pi-runtime.js +128 -0
- package/src/preparation.js +238 -0
- package/src/presentations.js +74 -0
- package/src/prompts.js +157 -0
- package/src/repo-config.js +72 -0
- package/src/repo-context.js +43 -0
- package/src/review-workspace.js +331 -0
- package/src/reviewer-limits.js +10 -0
- package/src/reviewer-response.js +22 -0
- package/src/reviewer.js +522 -0
- package/src/routing.js +38 -0
- package/src/sessions.js +368 -0
- package/src/terminal-launch.js +288 -0
- package/src/terminal-worker.js +11 -0
- package/src/terminal.js +119 -0
- package/src/usage.js +31 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"scope": "Synthetic fixture questions and authored answers only. These are known failures, not passing model assertions.",
|
|
4
|
+
"cases": [
|
|
5
|
+
{
|
|
6
|
+
"id": "v18-cosmetic-sufficient-qualitative-answer",
|
|
7
|
+
"source": {
|
|
8
|
+
"caseId": "cosmetic",
|
|
9
|
+
"requiredPaths": [
|
|
10
|
+
"styles.css",
|
|
11
|
+
"index.html"
|
|
12
|
+
],
|
|
13
|
+
"definition": {
|
|
14
|
+
"context": "A task application shares the button class between task-card actions and toolbar actions. This patch should change only card buttons, without changing toolbar controls or click behavior.",
|
|
15
|
+
"before": {
|
|
16
|
+
"styles.css": ".button { padding-block: 4px; padding-inline: 12px; }\n",
|
|
17
|
+
"index.html": "<main><nav class=\"toolbar\"><button class=\"button\">Refresh</button></nav><article class=\"card\"><button class=\"button\">Complete task</button></article></main>\n"
|
|
18
|
+
},
|
|
19
|
+
"after": {
|
|
20
|
+
"styles.css": ".button { padding-block: 4px; padding-inline: 12px; }\n.card .button { padding-block: 8px; }\n"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"definitionSha256": "50723ddddf259d210e5ce53f4c64bfdf4a0cbc7df6e656aafa0b897da3332085"
|
|
24
|
+
},
|
|
25
|
+
"question": {
|
|
26
|
+
"id": "card-button-padding-scope-qual",
|
|
27
|
+
"concept": "Scoped descendant selector overriding a shared class across two surfaces",
|
|
28
|
+
"prompt": "The styles.css diff adds a `.card .button` rule after the base `.button` rule, which sets padding on both axes. index.html has a `.button` inside `nav.toolbar` (Refresh) and a `.button` inside `article.card` (Complete task). For each button, predict whether its vertical padding and its horizontal padding change compared with before the patch, then briefly explain which selector and markup facts determine each outcome.",
|
|
29
|
+
"choices": [],
|
|
30
|
+
"evidence": [
|
|
31
|
+
{
|
|
32
|
+
"path": "styles.css",
|
|
33
|
+
"reason": "The diff adds a `.card .button` rule after the base `.button` rule and declares only the block-direction padding in the new rule, so overriding applies to one axis only."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "index.html",
|
|
37
|
+
"reason": "Refresh is inside nav.toolbar, outside article.card, so the descendant selector cannot match it; Complete task is inside article.card, so it matches."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"acceptance": {
|
|
41
|
+
"prediction": "Complete task's block-direction (vertical) padding changes while its inline-direction (horizontal) padding stays the same, and Refresh's padding changes on neither axis.",
|
|
42
|
+
"causalReason": "The .card .button rule matches only the button inside article.card and sets only block-direction padding, so it overrides the base .button value on that axis while the base rule still supplies inline padding."
|
|
43
|
+
},
|
|
44
|
+
"expectedAnswer": "Complete task (inside .card): its vertical padding changes, and its horizontal padding does not. The later .card .button rule matches it and has higher specificity than .button, so it wins for the block axis; that rule sets only padding-block, so the inline axis still comes from the base .button rule. Refresh (in nav.toolbar, outside .card): neither axis changes, because .card .button requires a .card ancestor and only the base .button rule matches it.",
|
|
45
|
+
"rationale": "Causal claims: (1) the new rule is scoped by the .card ancestor, so the toolbar button outside article.card does not match; (2) the descendant selector's higher specificity beats the base .button rule for padding-block where both apply; (3) padding-inline is unchanged because the new rule declares only padding-block, so the base rule still supplies it for the card button. The question asks for a qualitative per-axis prediction and cause, not exact values or specificity numbers. Evidence: the styles.css diff adds the .card .button rule after the base .button rule and sets only padding-block in the new rule; index.html places Refresh in nav.toolbar and Complete task inside article.card."
|
|
46
|
+
},
|
|
47
|
+
"answer": "Only buttons inside a card receive the new padding because the new selector requires a card ancestor. The toolbar button shares the button class but is outside a card, so its styling stays unchanged. The base button rule, horizontal padding, and click behavior remain unchanged.",
|
|
48
|
+
"expectedVerdict": "correct",
|
|
49
|
+
"observedVerdict": "ambiguous",
|
|
50
|
+
"observedFeedback": "The question, criteria, and sourceKey match the source: .card .button sets only padding-block, so Complete task's vertical padding changes while its horizontal padding stays from the base rule, and Refresh is unaffected. The answer correctly says the toolbar button and horizontal padding are unchanged and that the new selector needs a card ancestor. It never states that the vertical (block-direction) padding is what changes for Complete task, so the required per-axis prediction is missing. Please state which axis changes for the card button and why.",
|
|
51
|
+
"independentFinding": "New card padding together with unchanged horizontal padding communicates the vertical change. The answer also explains the card ancestor and unchanged toolbar boundary; requiring the literal word vertical is a false negative.",
|
|
52
|
+
"provenance": {
|
|
53
|
+
"run": "V18",
|
|
54
|
+
"artifact": "sphinx-eval-acceptance-haiku-v18.json",
|
|
55
|
+
"completedAt": "2026-10-15T23:22:29.682Z",
|
|
56
|
+
"model": "anthropic/claude-haiku-5.5",
|
|
57
|
+
"maxOutputTokens": 8192,
|
|
58
|
+
"sourceSha256": "9bbcff04e1281c1178ce44f14def6f2c7e26b739abd632489060009cf7b65eaf",
|
|
59
|
+
"note": "Exact legacy V16 candidate after the V18 minimum-criteria audit; not replaced with the later fresh V19 question."
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "v20-auth-sufficient-response-equivalence",
|
|
64
|
+
"source": {
|
|
65
|
+
"caseId": "auth",
|
|
66
|
+
"requiredPaths": [
|
|
67
|
+
"auth/document.js"
|
|
68
|
+
],
|
|
69
|
+
"definition": {
|
|
70
|
+
"context": "A multi-tenant document service. A signed-in user may read only documents belonging to their tenant. Missing documents and denied documents share a 404 response.",
|
|
71
|
+
"before": {
|
|
72
|
+
"auth/document.js": "export function getDocument(user, id, documents) { if (!user) return { status: 401 }; const doc = documents.get(id); return doc ? { status: 200, body: doc.text } : { status: 404 }; }\n"
|
|
73
|
+
},
|
|
74
|
+
"after": {
|
|
75
|
+
"auth/document.js": "export function getDocument(user, id, documents) { if (!user) return { status: 401 }; const doc = documents.get(id); if (!doc || doc.tenantId !== user.tenantId) return { status: 404 }; return { status: 200, body: doc.text }; }\n"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"definitionSha256": "4ed2d01d0cf01d73223c0d35d52961e954818a6394605c1dd78cc0ad886da846"
|
|
79
|
+
},
|
|
80
|
+
"question": {
|
|
81
|
+
"id": "missing-vs-denied-indistinguishable",
|
|
82
|
+
"concept": "Missing vs. denied response equivalence",
|
|
83
|
+
"prompt": "Can a signed-in user tell a nonexistent document ID apart from an existing document owned by another tenant by the getDocument response? Answer yes or no and give a short reason.",
|
|
84
|
+
"choices": [],
|
|
85
|
+
"expectedAnswer": "No. Both produce the identical { status: 404 } object with no body, because the missing-document case (!doc) and the cross-tenant case (doc.tenantId !== user.tenantId) are combined in one condition and return the same value.",
|
|
86
|
+
"rationale": "The combined condition returns the same { status: 404 } for both cases, matching the project context that missing and denied documents share a 404. The 401 check runs before the lookup, so unauthenticated callers get 401 regardless of whether the ID exists; that boundary is not asked here.",
|
|
87
|
+
"evidence": [
|
|
88
|
+
{
|
|
89
|
+
"path": "auth/document.js",
|
|
90
|
+
"reason": "The combined `!doc || doc.tenantId !== user.tenantId` condition returns the same { status: 404 } for both cases."
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"acceptance": {
|
|
94
|
+
"prediction": "No, both cases return the same 404 response.",
|
|
95
|
+
"causalReason": "The single combined condition returns the same { status: 404 } for a missing doc and a tenant mismatch."
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"answer": "A signed-in user can read only a document with the same tenantId. A different tenant gets 404 without document contents, just like a missing id. A caller without a user gets 401 before any document lookup or tenant comparison, because the missing-user guard returns first. Knowing an id or being authenticated is not enough to cross the tenant boundary.",
|
|
99
|
+
"expectedVerdict": "correct",
|
|
100
|
+
"observedVerdict": "ambiguous",
|
|
101
|
+
"observedFeedback": "The source supports the question and its criteria. The combined condition returns the same { status: 404 } for a missing document and a cross-tenant document, so the response does not distinguish them. The answer states that a different tenant gets 404 \"just like a missing id,\" which conveys the outcome, but it never gives an explicit yes or no and does not explain the causal mechanism: that one combined check (!doc || doc.tenantId !== user.tenantId) returns the same value for both cases. Please clarify whether the answer is no and why the single combined condition produces identical responses.",
|
|
102
|
+
"independentFinding": "A bodyless 404 for a different tenant just like a missing id communicates response equivalence and its causal basis. Naming one combined if statement or adding an explicit no is unnecessary.",
|
|
103
|
+
"provenance": {
|
|
104
|
+
"run": "V20",
|
|
105
|
+
"artifact": "sphinx-eval-live-remaining-haiku-v20.json",
|
|
106
|
+
"completedAt": "2026-10-15T23:34:17.636Z",
|
|
107
|
+
"model": "anthropic/claude-haiku-5.5",
|
|
108
|
+
"maxOutputTokens": 8192,
|
|
109
|
+
"assessmentContract": 3,
|
|
110
|
+
"sourceSha256": "d7d13eeafc213420d9f2d999c11a98751613e015eb5556a826920e98eb514661",
|
|
111
|
+
"note": "Freshly generated and source-audited question with minimum acceptance criteria; failure occurred on the current contract."
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Local UI fixture only: no real Git operation, provider, credentials, or home edits.
|
|
2
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { createBrowserInterview } from '../src/browser-interview.js';
|
|
8
|
+
import { createSessionStore } from '../src/sessions.js';
|
|
9
|
+
import { DEFAULT_CONFIG } from '../src/config.js';
|
|
10
|
+
|
|
11
|
+
const home = await mkdtemp(path.join(tmpdir(), 'sphinx-browser-demo-'));
|
|
12
|
+
const repoRoot = path.join(home, 'example-project'); await mkdir(repoRoot);
|
|
13
|
+
const env = { SPHINX_HOME: path.join(home, 'state'), SPHINX_USER_HOME: home }, config = structuredClone(DEFAULT_CONFIG);
|
|
14
|
+
const snapshot = { id: 'd'.repeat(64), repoRoot, event: 'pre-push', target: { ref: 'refs/heads/main' }, files: ['src/queue.js'], stats: { additions: 23, deletions: 0, files: 1 } };
|
|
15
|
+
const question = { id: 'duplicate', concept: 'Duplicate requests', prompt: 'A job finishes successfully, but the caller times out before receiving the response. If the caller retries with the same job ID in the same process, will this change prevent the job from running twice? Explain why.', choices: [], expectedAnswer: 'The process-local set rejects a duplicate until the process restarts.', rationale: 'The set is held in memory.', evidence: [{ path: 'src/queue.js', reason: 'Process-local membership is checked before execution.' }] };
|
|
16
|
+
const store = createSessionStore({ env,
|
|
17
|
+
generate: async (_snapshot, _config, { beforeCall }) => { await beforeCall(); await delay(300); return { questions: [question] }; },
|
|
18
|
+
grade: async request => { await request.beforeCall(); await delay(500); return request.learn || request.dispute || /wrong/i.test(request.answer)
|
|
19
|
+
? { verdict: request.dispute ? 'ambiguous' : 'incorrect', feedback: 'This set lives in one process. It remembers IDs while that process is running; a restart creates an empty set.', followUp: { ...question, id: randomUUID(), prompt: 'The process restarts after completing a job. The caller retries that job ID. Can the same job run again, and why?' } }
|
|
20
|
+
: { verdict: 'correct', feedback: 'You connected the behavior to the lifetime of the stored IDs.', followUp: null }; },
|
|
21
|
+
});
|
|
22
|
+
await store.ensure(snapshot, config);
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
process.on('SIGINT', () => controller.abort()); process.on('SIGTERM', () => controller.abort());
|
|
25
|
+
const interview = createBrowserInterview({ env, signal: controller.signal, store, readConfig: async () => config, revalidate: async () => true, openBrowser: async () => false, output: console.log });
|
|
26
|
+
console.log('Sphinx browser UI fixture. No real repository, model, or Git approval is involved.');
|
|
27
|
+
try { console.log(await interview.present({ request: { id: snapshot.id, repoRoot, token: randomUUID(), createdAt: Date.now(), delivery: 'hook' } })); }
|
|
28
|
+
finally { await delay(500); await interview.close(); await rm(home, { recursive: true, force: true }); }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"provider": "openrouter",
|
|
5
|
+
"model": "anthropic/claude-haiku-5.5",
|
|
6
|
+
"level": "standard",
|
|
7
|
+
"mode": "block",
|
|
8
|
+
"events": [
|
|
9
|
+
"pre-push"
|
|
10
|
+
],
|
|
11
|
+
"branches": [
|
|
12
|
+
"main",
|
|
13
|
+
"master"
|
|
14
|
+
],
|
|
15
|
+
"remotes": [
|
|
16
|
+
"*"
|
|
17
|
+
],
|
|
18
|
+
"exclude": [],
|
|
19
|
+
"maxContextBytes": 100000,
|
|
20
|
+
"timeoutSeconds": 180,
|
|
21
|
+
"receiptHours": 24,
|
|
22
|
+
"maxModelCalls": null,
|
|
23
|
+
"maxOutputTokens": 8192,
|
|
24
|
+
"allowPaid": true,
|
|
25
|
+
"agentMaxTurns": null,
|
|
26
|
+
"toolResultBytes": 16000,
|
|
27
|
+
"backgroundPreparation": true,
|
|
28
|
+
"preparationDebounceMs": 1500,
|
|
29
|
+
"models": {
|
|
30
|
+
"small": {
|
|
31
|
+
"provider": "openrouter",
|
|
32
|
+
"model": "anthropic/claude-haiku-5.5"
|
|
33
|
+
},
|
|
34
|
+
"medium": {
|
|
35
|
+
"provider": "openrouter",
|
|
36
|
+
"model": "anthropic/claude-haiku-5.5"
|
|
37
|
+
},
|
|
38
|
+
"large": {
|
|
39
|
+
"provider": "openrouter",
|
|
40
|
+
"model": "anthropic/claude-haiku-5.5"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"routing": {
|
|
44
|
+
"smallMaxLines": 80,
|
|
45
|
+
"smallMaxFiles": 3,
|
|
46
|
+
"largeMinLines": 400,
|
|
47
|
+
"largeMinFiles": 12,
|
|
48
|
+
"riskPatterns": [
|
|
49
|
+
"auth",
|
|
50
|
+
"permission",
|
|
51
|
+
"migration",
|
|
52
|
+
"billing",
|
|
53
|
+
"payment",
|
|
54
|
+
"crypto",
|
|
55
|
+
"security",
|
|
56
|
+
"terraform"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sphinx-understand",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Outsource your thinking, not your understanding. A comprehension checkpoint for agent-assisted development.",
|
|
5
|
+
"homepage": "https://github.com/parthh01/sphinx#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/parthh01/sphinx.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/parthh01/sphinx/issues"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"sphinx": "bin/sphinx.js"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.19.0"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node scripts/run-suite.mjs",
|
|
22
|
+
"check": "node --check bin/sphinx.js && node scripts/run-suite.mjs",
|
|
23
|
+
"start": "node bin/sphinx.js",
|
|
24
|
+
"eval": "node eval/run.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"src",
|
|
29
|
+
"docs",
|
|
30
|
+
"eval/RESULTS.md",
|
|
31
|
+
"eval/fixtures/known-false-negatives.json",
|
|
32
|
+
"examples",
|
|
33
|
+
".sphinxconfig.example",
|
|
34
|
+
".sphinxignore.example",
|
|
35
|
+
"README.md",
|
|
36
|
+
"CONTRIBUTING.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"git",
|
|
42
|
+
"hooks",
|
|
43
|
+
"codex",
|
|
44
|
+
"claude-code",
|
|
45
|
+
"learning",
|
|
46
|
+
"code-review"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "1.2.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { runActivationTerminal } from './activation.js';
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
const result = await runActivationTerminal({ recordFile: process.argv[2], token: process.argv[3] });
|
|
5
|
+
process.exitCode = result.code;
|
|
6
|
+
if (result.status === 'failed') process.stderr.write('Sphinx could not start this coding client. Open it manually in this repository.\n');
|
|
7
|
+
} catch {
|
|
8
|
+
process.stderr.write('Sphinx could not start this coding client. Run sphinx activate to try again.\n');
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { access, lstat, mkdir, open, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { spawn, execFile } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { getPaths, readJson, writeJson } from './config.js';
|
|
9
|
+
import { terminalCommand, spawnTerminal, createTerminalEnvironmentBridge, readTerminalEnvironment } from './terminal-launch.js';
|
|
10
|
+
|
|
11
|
+
const WORKER = fileURLToPath(new URL('./activation-worker.js', import.meta.url));
|
|
12
|
+
const AGENTS = ['codex', 'claude', 'pi', 'cursor', 'opencode'];
|
|
13
|
+
const TOKEN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
14
|
+
const SESSION_ENV = ['CODEX_THREAD_ID', 'CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT', 'PI_SESSION_ID', 'SPHINX_CLAUDE_SESSION', 'SPHINX_CURSOR_SESSION', 'SPHINX_OPENCODE_SESSION'];
|
|
15
|
+
const LABELS = { codex: 'Codex', claude: 'Claude Code', pi: 'Pi', cursor: 'Cursor', opencode: 'OpenCode' };
|
|
16
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
17
|
+
const desktop = (env, platform) => !env.SSH_CONNECTION && !env.SSH_TTY && !(env.CI && env.CI !== 'false' && env.CI !== '0') && (platform === 'darwin' || (platform === 'linux' && !!(env.DISPLAY || env.WAYLAND_DISPLAY)));
|
|
18
|
+
const freshEnvironment = env => Object.fromEntries(Object.entries(env).filter(([key]) => !SESSION_ENV.includes(key)));
|
|
19
|
+
const fileFor = (home, token) => path.join(home, 'activations', `${token}.json`);
|
|
20
|
+
|
|
21
|
+
function clientEnvironment(env) {
|
|
22
|
+
const fresh = freshEnvironment(env);
|
|
23
|
+
if (!env.SPHINX_USER_HOME) return fresh;
|
|
24
|
+
const paths = getPaths(env);
|
|
25
|
+
return { ...fresh, HOME: paths.userHome, CODEX_HOME: paths.codexHome, CLAUDE_CONFIG_DIR: paths.claudeHome, PI_CODING_AGENT_DIR: path.join(paths.userHome, '.pi', 'agent'), OPENCODE_CONFIG_DIR: path.join(paths.userHome, '.config', 'opencode'), XDG_CONFIG_HOME: path.join(paths.userHome, '.config'), XDG_DATA_HOME: path.join(paths.userHome, '.local', 'share'), XDG_STATE_HOME: path.join(paths.userHome, '.local', 'state'), XDG_CACHE_HOME: path.join(paths.userHome, '.cache') };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function executable(name, env) {
|
|
29
|
+
for (const directory of (env.PATH || '').split(path.delimiter)) {
|
|
30
|
+
if (!directory) continue;
|
|
31
|
+
const file = path.resolve(directory, name);
|
|
32
|
+
try { if ((await stat(file)).isFile()) { await access(file, constants.X_OK); return file; } } catch {}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function cursorExecutable(env, platform, findExecutable) {
|
|
38
|
+
const fromPath = await findExecutable('cursor', env);
|
|
39
|
+
if (fromPath || platform !== 'darwin') return fromPath;
|
|
40
|
+
// Use the editor's own launcher when "Install cursor command" was not run.
|
|
41
|
+
// Test homes must never fall through to the real machine's Applications.
|
|
42
|
+
const roots = env.SPHINX_USER_HOME ? [path.join(getPaths(env).userHome, 'Applications')] : ['/Applications', path.join(getPaths(env).userHome, 'Applications')];
|
|
43
|
+
for (const root of roots) {
|
|
44
|
+
const file = path.join(root, 'Cursor.app', 'Contents', 'Resources', 'app', 'bin', 'code');
|
|
45
|
+
try { if ((await stat(file)).isFile()) { await access(file, constants.X_OK); return file; } } catch {}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function clientVersion(binary, env) {
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
execFile(binary, ['--version'], { env, timeout: 2000, maxBuffer: 4096, encoding: 'utf8' }, (error, stdout) => {
|
|
53
|
+
if (error) { resolve(null); return; }
|
|
54
|
+
const match = /^(?:Cursor\s+)?(\d+)\.(\d+)(?:\.(\d+))?(?:[-+][^\s]*)?\s*$/im.exec(stdout);
|
|
55
|
+
resolve(match ? `${match[1]}.${match[2]}.${match[3] || 0}` : null);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function activationDirectory(home) {
|
|
61
|
+
const directory = path.join(home, 'activations');
|
|
62
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
63
|
+
if (info && (info.isSymbolicLink() || !info.isDirectory())) throw new Error('Refusing an unsafe client activation directory.');
|
|
64
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
return directory;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Instructions stay in the terminal; they are never submitted as a model prompt. */
|
|
69
|
+
export function activationGuidance(agent) {
|
|
70
|
+
const steps = {
|
|
71
|
+
codex: 'In Codex, open /hooks to review and trust the Sphinx hooks.',
|
|
72
|
+
claude: 'Respond to any Claude setup or MCP permission prompts. Use /mcp to inspect Sphinx.',
|
|
73
|
+
pi: 'Blocking Git checks use a local browser page. Pi dialogs handle fallback checkpoints.',
|
|
74
|
+
opencode: 'When Git requires a checkpoint, Sphinx serves a local browser questionnaire without moving your coding window.',
|
|
75
|
+
};
|
|
76
|
+
return `Sphinx · ${LABELS[agent]}\n${steps[agent]}\nStarting a fresh session in this repository. No prompt has been submitted.\n\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Open configured clients only; the caller supplies the installed selection. */
|
|
80
|
+
export async function activateIntegrations({ agents = [], cwd = process.cwd(), env = process.env, platform = process.platform, findExecutable = executable, readClientVersion = clientVersion, launch = spawnTerminal, environmentBridge = createTerminalEnvironmentBridge, timeoutMs = 20000, pollMs = 50, wait = delay } = {}) {
|
|
81
|
+
if (!Array.isArray(agents) || agents.some(agent => !AGENTS.includes(agent))) throw new Error('Choose codex, claude, pi, cursor, or opencode for activation.');
|
|
82
|
+
if (typeof cwd !== 'string' || cwd.includes('\0') || !path.isAbsolute(cwd)) throw new Error('Client activation needs an absolute repository directory.');
|
|
83
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error('Client activation needs an existing directory.');
|
|
84
|
+
const home = getPaths(env).home;
|
|
85
|
+
const results = await Promise.all([...new Set(agents)].map(async agent => {
|
|
86
|
+
const command = agent === 'cursor' ? `cursor --new-window ${quote(cwd)}` : agent;
|
|
87
|
+
const result = (status, detail) => ({ agent, status, detail, command, cwd });
|
|
88
|
+
if (env.SPHINX_EVALUATOR === '1') return result('unavailable', 'Client activation is disabled inside an evaluator.');
|
|
89
|
+
if (!desktop(env, platform)) return result('unavailable', `No local desktop is available. Open a terminal in this repository and run ${command}.`);
|
|
90
|
+
let binary;
|
|
91
|
+
try { binary = agent === 'cursor' ? await cursorExecutable(env, platform, findExecutable) : await findExecutable(agent, env); }
|
|
92
|
+
catch { return result('unavailable', `Could not locate ${LABELS[agent]}. Open it manually in this repository.`); }
|
|
93
|
+
if (binary && (!path.isAbsolute(binary) || binary.includes('\0'))) return result('unavailable', `Could not locate a valid ${LABELS[agent]} executable.`);
|
|
94
|
+
if (!binary) return result('unavailable', `${LABELS[agent]} is configured but its executable is not on PATH. Open it manually in this repository.`);
|
|
95
|
+
if (agent === 'cursor') {
|
|
96
|
+
// The editor CLI has this spelling; never substitute cursor-agent or agent.
|
|
97
|
+
try {
|
|
98
|
+
const version = await readClientVersion(binary, clientEnvironment(env));
|
|
99
|
+
if (/^\d+\.\d+\.\d+$/.test(version || '')) {
|
|
100
|
+
const [major, minor] = version.split('.').map(Number);
|
|
101
|
+
// Hooks first appeared in Cursor 1.7. Passing this floor does not
|
|
102
|
+
// certify support for every current hook event or verify permissions.
|
|
103
|
+
if (major < 1 || (major === 1 && minor < 7)) return result('unavailable', `Cursor ${version} predates hook support. Update Cursor, then run sphinx activate --agents cursor.`);
|
|
104
|
+
}
|
|
105
|
+
const opened = await launch({ command: binary, args: ['--new-window', cwd], waitForExit: true }, { env: clientEnvironment(env) });
|
|
106
|
+
return opened ? result('opened', 'Opened a new Cursor editor window. Start a new agent chat there; hook loading and permissions remain controlled by Cursor.') : result('failed', 'Cursor did not accept the window launch. Open the editor manually in this repository.');
|
|
107
|
+
} catch { return result('failed', 'Cursor could not be opened. Open the editor manually in this repository.'); }
|
|
108
|
+
}
|
|
109
|
+
let bridge, recordFile, claimFile;
|
|
110
|
+
try {
|
|
111
|
+
await activationDirectory(home);
|
|
112
|
+
const token = randomUUID();
|
|
113
|
+
recordFile = fileFor(home, token); claimFile = `${recordFile}.claim`;
|
|
114
|
+
const terminal = await terminalCommand({ recordFile, token, home, platform, env, findExecutable, workerPath: WORKER });
|
|
115
|
+
if (!terminal) return result('unavailable', `No supported desktop terminal was found. Open a terminal in this repository and run ${command}.`);
|
|
116
|
+
bridge = await environmentBridge(clientEnvironment(env), token);
|
|
117
|
+
await writeJson(recordFile, { version: 1, token, home, agent, command: binary, cwd, envSocket: bridge.socketPath, status: 'starting', createdAt: Date.now() });
|
|
118
|
+
const accepted = await launch(terminal, { env: clientEnvironment(env) });
|
|
119
|
+
if (!accepted) return result('failed', `Could not open a terminal for ${LABELS[agent]}. Run ${command} in this repository.`);
|
|
120
|
+
// Keep the one-shot IPC alive until the worker has received the environment
|
|
121
|
+
// and its interactive client emits spawn. Never wait for the session to end.
|
|
122
|
+
const deadline = Date.now() + timeoutMs;
|
|
123
|
+
do {
|
|
124
|
+
const current = await readJson(recordFile, null);
|
|
125
|
+
if (current?.token !== token) return result('failed', `The ${LABELS[agent]} launch could not be confirmed. Open it manually.`);
|
|
126
|
+
if (current.status === 'opened') return result('opened', agent === 'codex' ? 'Started a fresh Codex session. Open /hooks there to review and trust Sphinx hooks.' : `Started a fresh ${LABELS[agent]} session. Complete any setup or permissions in that terminal.`);
|
|
127
|
+
if (current.status === 'failed') return result('failed', `${LABELS[agent]} could not start in the terminal. Run ${command} in this repository.`);
|
|
128
|
+
if (Date.now() >= deadline) break;
|
|
129
|
+
await wait(Math.min(pollMs, deadline - Date.now()));
|
|
130
|
+
} while (true);
|
|
131
|
+
return result('failed', `The terminal opened, but ${LABELS[agent]} startup was not confirmed. Check that terminal or run ${command} manually.`);
|
|
132
|
+
} catch { return result('failed', `Could not start ${LABELS[agent]}. Run ${command} in this repository.`); }
|
|
133
|
+
finally {
|
|
134
|
+
await bridge?.close().catch(() => {});
|
|
135
|
+
if (recordFile) await rm(recordFile, { force: true }).catch(() => {});
|
|
136
|
+
if (claimFile) await rm(claimFile, { force: true }).catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
return { results };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Runs in the opened terminal, never in the installing coding-agent session. */
|
|
143
|
+
export async function runActivationTerminal({ recordFile, token, env = process.env, isTTY = !!process.stdin.isTTY, spawnImpl = spawn, write = text => process.stdout.write(text), startupGraceMs = 300 } = {}) {
|
|
144
|
+
const home = getPaths(env).home;
|
|
145
|
+
if (!TOKEN.test(token) || recordFile !== fileFor(home, token)) throw new Error('Invalid client activation worker.');
|
|
146
|
+
await activationDirectory(home);
|
|
147
|
+
let claim;
|
|
148
|
+
try { claim = await open(`${recordFile}.claim`, 'wx', 0o600); }
|
|
149
|
+
catch (error) { if (error.code === 'EEXIST') return { status: 'ignored', code: 1 }; throw error; }
|
|
150
|
+
try {
|
|
151
|
+
const record = await readJson(recordFile, null);
|
|
152
|
+
if (!record || record.version !== 1 || record.token !== token || record.home !== home || !AGENTS.filter(agent => agent !== 'cursor').includes(record.agent) || record.status !== 'starting' || !path.isAbsolute(record.command || '') || record.command.includes('\0') || !path.isAbsolute(record.cwd || '') || record.cwd.includes('\0')) return { status: 'ignored', code: 1 };
|
|
153
|
+
const status = async value => {
|
|
154
|
+
// Installation can time out or finish while this worker remains alive.
|
|
155
|
+
// It owns cleanup; do not recreate an already removed launch record.
|
|
156
|
+
try {
|
|
157
|
+
if ((await readJson(recordFile, null))?.token === token) await writeJson(recordFile, { ...record, status: value });
|
|
158
|
+
} catch { /* The parent reports unconfirmed startup if acknowledgment fails. */ }
|
|
159
|
+
};
|
|
160
|
+
if (!isTTY) { await status('failed'); return { status: 'failed', code: 1 }; }
|
|
161
|
+
let providerEnv;
|
|
162
|
+
try { providerEnv = await readTerminalEnvironment(record.envSocket, token); }
|
|
163
|
+
catch { await status('failed'); return { status: 'failed', code: 1 }; }
|
|
164
|
+
if (!(await readJson(recordFile, null))) return { status: 'ignored', code: 1 };
|
|
165
|
+
write(activationGuidance(record.agent));
|
|
166
|
+
let opened = false, stateWrite = Promise.resolve();
|
|
167
|
+
const code = await new Promise(resolve => {
|
|
168
|
+
let child, timer;
|
|
169
|
+
try { child = spawnImpl(record.command, [], { cwd: record.cwd, env: { ...freshEnvironment(env), ...providerEnv, SPHINX_HOME: home }, stdio: 'inherit' }); }
|
|
170
|
+
catch { stateWrite = status('failed'); resolve(1); return; }
|
|
171
|
+
child.once('spawn', () => {
|
|
172
|
+
timer = setTimeout(() => { opened = true; stateWrite = stateWrite.then(() => status('opened')); }, startupGraceMs);
|
|
173
|
+
});
|
|
174
|
+
child.once('error', () => { clearTimeout(timer); stateWrite = stateWrite.then(() => status('failed')); resolve(1); });
|
|
175
|
+
child.once('exit', code => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (!opened) stateWrite = stateWrite.then(() => status('failed'));
|
|
178
|
+
resolve(code ?? 1);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
await stateWrite;
|
|
182
|
+
return { status: opened ? 'closed' : 'failed', code };
|
|
183
|
+
} finally {
|
|
184
|
+
await claim.close();
|
|
185
|
+
await rm(`${recordFile}.claim`, { force: true }).catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
}
|