@ctrl-spc/cli 1.3.1 → 1.3.3
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/dist/commands/fetch.js +108 -43
- package/dist/commands/init.js +271 -53
- package/dist/commands/link.js +117 -16
- package/dist/commands/run.js +187 -34
- package/dist/companion-projects.js +375 -0
- package/dist/companion-ui.js +1203 -0
- package/dist/companion.js +422 -0
- package/dist/dispatch.js +114 -35
- package/dist/git.js +74 -4
- package/dist/index.js +12 -0
- package/dist/mcp.js +124 -97
- package/dist/relink.js +31 -0
- package/dist/resolver.js +9 -0
- package/dist/role-detection.js +101 -0
- package/dist/roles.js +18 -0
- package/dist/runtime-control.js +248 -0
- package/dist/supabase.js +11 -0
- package/dist/topology.js +22 -4
- package/package.json +6 -1
package/dist/git.js
CHANGED
|
@@ -1,4 +1,73 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
export const LOCAL_REPOSITORY_KEY_PREFIX = 'local:v1:';
|
|
4
|
+
const LOCAL_REPOSITORY_CONFIG_KEY = 'ctrl-spc.repository-id';
|
|
5
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
function gitOutput(cwd, args) {
|
|
7
|
+
try {
|
|
8
|
+
const value = execFileSync('git', args, {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
|
+
}).trim();
|
|
13
|
+
return value || null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function localRepositoryKey(id) {
|
|
20
|
+
if (!UUID_PATTERN.test(id))
|
|
21
|
+
throw new Error('Invalid local repository identity.');
|
|
22
|
+
return `${LOCAL_REPOSITORY_KEY_PREFIX}${id.toLowerCase()}`;
|
|
23
|
+
}
|
|
24
|
+
export function isLocalRepositoryKey(value) {
|
|
25
|
+
return value.toLowerCase().startsWith(LOCAL_REPOSITORY_KEY_PREFIX);
|
|
26
|
+
}
|
|
27
|
+
export function readLocalRepositoryIdentity(cwd) {
|
|
28
|
+
const id = gitOutput(cwd, ['config', '--local', '--get', LOCAL_REPOSITORY_CONFIG_KEY]);
|
|
29
|
+
return id && UUID_PATTERN.test(id) ? localRepositoryKey(id) : null;
|
|
30
|
+
}
|
|
31
|
+
export function readRootCommitSha(cwd) {
|
|
32
|
+
const roots = gitOutput(cwd, ['rev-list', '--max-parents=0', 'HEAD']);
|
|
33
|
+
return roots?.split(/\s+/).filter(Boolean).sort()[0] ?? null;
|
|
34
|
+
}
|
|
35
|
+
/** Writes untracked Git metadata so identity survives moves and copied folders. */
|
|
36
|
+
export function ensureLocalRepositoryIdentity(cwd, createId = randomUUID) {
|
|
37
|
+
const existing = readLocalRepositoryIdentity(cwd);
|
|
38
|
+
if (existing)
|
|
39
|
+
return existing;
|
|
40
|
+
if (!gitOutput(cwd, ['rev-parse', '--show-toplevel'])) {
|
|
41
|
+
throw new Error(`Not a Git repository: ${cwd}`);
|
|
42
|
+
}
|
|
43
|
+
const id = createId();
|
|
44
|
+
const key = localRepositoryKey(id);
|
|
45
|
+
execFileSync('git', ['config', '--local', LOCAL_REPOSITORY_CONFIG_KEY, id.toLowerCase()], {
|
|
46
|
+
cwd,
|
|
47
|
+
stdio: 'ignore',
|
|
48
|
+
});
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
export function writeLocalRepositoryIdentity(cwd, key, allowedPriorKey) {
|
|
52
|
+
if (!isLocalRepositoryKey(key))
|
|
53
|
+
throw new Error('Expected a local repository identity.');
|
|
54
|
+
const id = key.slice(LOCAL_REPOSITORY_KEY_PREFIX.length);
|
|
55
|
+
localRepositoryKey(id);
|
|
56
|
+
const existing = readLocalRepositoryIdentity(cwd);
|
|
57
|
+
if (existing && existing !== key && existing !== allowedPriorKey) {
|
|
58
|
+
throw new Error('This folder is already linked to a different CTRL+SPC repository.');
|
|
59
|
+
}
|
|
60
|
+
if (!gitOutput(cwd, ['rev-parse', '--show-toplevel']))
|
|
61
|
+
throw new Error(`Not a Git repository: ${cwd}`);
|
|
62
|
+
execFileSync('git', ['config', '--local', LOCAL_REPOSITORY_CONFIG_KEY, id], { cwd, stdio: 'ignore' });
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
export function currentRepoIdentity(cwd) {
|
|
66
|
+
const remote = currentRepoRemote(cwd);
|
|
67
|
+
if (remote)
|
|
68
|
+
return normalizeRemoteUrl(remote);
|
|
69
|
+
return readLocalRepositoryIdentity(cwd);
|
|
70
|
+
}
|
|
2
71
|
/**
|
|
3
72
|
* Normalizes a git remote URL to a canonical `host/path` key (no scheme,
|
|
4
73
|
* lowercase throughout — hosts are case-insensitive and GitHub/GitLab paths
|
|
@@ -52,13 +121,12 @@ export function currentRepoRemote(cwd) {
|
|
|
52
121
|
* (`init`/`link`) must not treat it as such.
|
|
53
122
|
*/
|
|
54
123
|
export async function resolveProject(client, cwd) {
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
124
|
+
const key = currentRepoIdentity(cwd);
|
|
125
|
+
if (!key)
|
|
57
126
|
return null;
|
|
58
|
-
const key = normalizeRemoteUrl(remote);
|
|
59
127
|
const { data, error } = await client
|
|
60
128
|
.from('projects')
|
|
61
|
-
.select('id, org_id, name, organizations(name)')
|
|
129
|
+
.select('id, org_id, name, archived_at, organizations(name)')
|
|
62
130
|
.eq('git_remote_url', key)
|
|
63
131
|
.maybeSingle();
|
|
64
132
|
if (error)
|
|
@@ -66,5 +134,7 @@ export async function resolveProject(client, cwd) {
|
|
|
66
134
|
if (!data)
|
|
67
135
|
return null;
|
|
68
136
|
const row = data;
|
|
137
|
+
if (row.archived_at)
|
|
138
|
+
return null;
|
|
69
139
|
return { id: row.id, orgId: row.org_id, name: row.name, orgName: row.organizations?.name ?? '' };
|
|
70
140
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,16 +5,19 @@ import { init } from './commands/init.js';
|
|
|
5
5
|
import { link } from './commands/link.js';
|
|
6
6
|
import { fetchRepository } from './commands/fetch.js';
|
|
7
7
|
import { run } from './commands/run.js';
|
|
8
|
+
import { openCompanion, serveCompanion } from './companion.js';
|
|
8
9
|
export const HELP_TEXT = `ctrl-spc — CTRL+SPC CLI
|
|
9
10
|
|
|
10
11
|
Usage:
|
|
11
12
|
ctrl-spc login Sign in via the browser and store a session
|
|
12
13
|
ctrl-spc logout Remove the stored session from this machine
|
|
13
14
|
ctrl-spc init Discover repositories and explicitly group them into projects
|
|
15
|
+
[--project <empty-project-id>] [--repo <path> ...] [--combine] [--yes]
|
|
14
16
|
ctrl-spc link Reconcile a project's full topology after grouping changes
|
|
15
17
|
[--project <project-id>]
|
|
16
18
|
ctrl-spc fetch Clone and link one unavailable project repository
|
|
17
19
|
[repository-id] [--project <project-id>] [--destination <path>] [--yes]
|
|
20
|
+
ctrl-spc open Open the local connection manager
|
|
18
21
|
ctrl-spc Run the local agent process (presence + MCP server)
|
|
19
22
|
ctrl-spc --help Show this help text
|
|
20
23
|
`;
|
|
@@ -36,6 +39,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
36
39
|
case 'fetch':
|
|
37
40
|
await fetchRepository(argv.slice(1));
|
|
38
41
|
return;
|
|
42
|
+
case 'open':
|
|
43
|
+
await openCompanion();
|
|
44
|
+
return;
|
|
45
|
+
case '__broker':
|
|
46
|
+
await run();
|
|
47
|
+
return;
|
|
48
|
+
case '__companion':
|
|
49
|
+
await serveCompanion();
|
|
50
|
+
return;
|
|
39
51
|
case '--help':
|
|
40
52
|
case '-h':
|
|
41
53
|
console.log(HELP_TEXT);
|
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { createServer as createHttpServer } from 'node:http';
|
|
4
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
6
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
@@ -8,6 +9,8 @@ import { z } from 'zod';
|
|
|
8
9
|
import { PROTOCOL_FULL, PROTOCOL_SHORT } from './protocol.js';
|
|
9
10
|
import { MCP_PORT } from './env.js';
|
|
10
11
|
import { getMachineIdentity } from './config.js';
|
|
12
|
+
import { fetchServerCapabilities } from './supabase.js';
|
|
13
|
+
import { resolveProjectRootRows } from './resolver.js';
|
|
11
14
|
/**
|
|
12
15
|
* From the MCP `initialize` request's `clientInfo.name` (spec: "Agent
|
|
13
16
|
* attribution: from the MCP initialize request's clientInfo.name — map
|
|
@@ -64,12 +67,14 @@ async function accessibleProjects(deps, machineId = deps.machineId) {
|
|
|
64
67
|
return [];
|
|
65
68
|
const rows = (await must(deps.client
|
|
66
69
|
.from('machine_workspaces')
|
|
67
|
-
.select('project_id,project:projects!machine_workspaces_project_id_fkey(id,name)')
|
|
70
|
+
.select('project_id,project:projects!machine_workspaces_project_id_fkey(id,name,archived_at)')
|
|
68
71
|
.eq('user_id', deps.userId)
|
|
69
72
|
.eq('machine_id', machineId))) ?? [];
|
|
70
73
|
const projects = new Map();
|
|
71
74
|
for (const row of rows) {
|
|
72
75
|
const project = relationOne(row.project);
|
|
76
|
+
if (project && 'archived_at' in project && project.archived_at !== null)
|
|
77
|
+
continue;
|
|
73
78
|
projects.set(row.project_id, { id: row.project_id, ...(project?.name ? { name: project.name } : {}) });
|
|
74
79
|
}
|
|
75
80
|
return [...projects.values()].sort((left, right) => (left.name ?? left.id).localeCompare(right.name ?? right.id) || left.id.localeCompare(right.id));
|
|
@@ -248,92 +253,64 @@ async function resolveOrCreateSprint(client, projectId, name) {
|
|
|
248
253
|
throw new Error('Sprint creation returned no row.');
|
|
249
254
|
return created.id;
|
|
250
255
|
}
|
|
251
|
-
async function loadAgentProjectTopology(client, projectId,
|
|
252
|
-
const project = await must(client.from('projects').select('topology_revision').eq('id', projectId).single());
|
|
256
|
+
export async function loadAgentProjectTopology(client, projectId, machineId, inspectPath = statSync) {
|
|
257
|
+
const project = await must(client.from('projects').select('topology_revision,archived_at').eq('id', projectId).single());
|
|
253
258
|
if (!project)
|
|
254
259
|
throw new Error('Project topology could not be loaded.');
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
position: row.position,
|
|
303
|
-
checkouts: candidates.map((candidate) => ({
|
|
304
|
-
id: candidate.id,
|
|
305
|
-
local_path: candidate.local_path,
|
|
306
|
-
availability: candidate.availability,
|
|
307
|
-
verified_remote_key: candidate.verified_remote_key,
|
|
308
|
-
head_sha: candidate.head_sha,
|
|
309
|
-
is_worktree: candidate.is_worktree,
|
|
310
|
-
verified_at: candidate.verified_at,
|
|
311
|
-
})),
|
|
312
|
-
checkout: checkout
|
|
313
|
-
? {
|
|
314
|
-
id: checkout.id,
|
|
315
|
-
local_path: checkout.local_path,
|
|
316
|
-
availability,
|
|
317
|
-
verified_remote_key: checkout.verified_remote_key,
|
|
318
|
-
head_sha: checkout.head_sha,
|
|
319
|
-
is_worktree: checkout.is_worktree,
|
|
320
|
-
verified_at: checkout.verified_at,
|
|
321
|
-
}
|
|
322
|
-
: { id: null, local_path: null, availability: 'missing' },
|
|
323
|
-
fetch_offer: availability === 'available'
|
|
324
|
-
? null
|
|
325
|
-
: {
|
|
326
|
-
available: Boolean(scope.repository.remote_key),
|
|
327
|
-
instruction: 'Warn the user that this repository is unavailable locally and offer to fetch it. Never fetch without approval.',
|
|
328
|
-
},
|
|
329
|
-
}];
|
|
260
|
+
if (project.archived_at)
|
|
261
|
+
throw new Error('This project is archived. Restore it in the web app before starting agent work.');
|
|
262
|
+
const rows = await resolveProjectRootRows(client, projectId, machineId);
|
|
263
|
+
const scopes = rows.map(row => {
|
|
264
|
+
const cwd = row.local_path ? join(row.local_path, row.scope_path) : null;
|
|
265
|
+
let status = row.status;
|
|
266
|
+
let remediation = row.remediation;
|
|
267
|
+
if (status === 'ready' && cwd) {
|
|
268
|
+
try {
|
|
269
|
+
inspectPath(cwd);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
status = 'missing';
|
|
273
|
+
remediation = row.source_kind === 'local' ? 'copy_or_promote' : 'fetch';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const availability = status === 'ready'
|
|
277
|
+
? 'available'
|
|
278
|
+
: status === 'unlabeled_role'
|
|
279
|
+
? row.availability ?? 'missing'
|
|
280
|
+
: status;
|
|
281
|
+
const checkout = {
|
|
282
|
+
id: row.checkout_id,
|
|
283
|
+
local_path: row.local_path,
|
|
284
|
+
cwd,
|
|
285
|
+
availability,
|
|
286
|
+
head_sha: row.head_sha,
|
|
287
|
+
is_worktree: row.is_worktree,
|
|
288
|
+
verified_at: row.verified_at,
|
|
289
|
+
};
|
|
290
|
+
return {
|
|
291
|
+
repository_scope_id: row.root_id,
|
|
292
|
+
role_slug: row.role_slug,
|
|
293
|
+
role_label: row.role_label,
|
|
294
|
+
identity_key: row.identity_key,
|
|
295
|
+
source_kind: row.source_kind,
|
|
296
|
+
scope_path: row.scope_path,
|
|
297
|
+
scope_kind: row.kind,
|
|
298
|
+
required: row.required,
|
|
299
|
+
position: row.position,
|
|
300
|
+
contained_in_root_id: row.contained_in_root_id,
|
|
301
|
+
status,
|
|
302
|
+
remediation,
|
|
303
|
+
cwd,
|
|
304
|
+
checkouts: row.checkout_id ? [checkout] : [],
|
|
305
|
+
checkout,
|
|
306
|
+
};
|
|
330
307
|
});
|
|
331
308
|
const missingRequiredScopeIds = scopes
|
|
332
|
-
.filter((scope) => scope.required && scope.
|
|
309
|
+
.filter((scope) => scope.required && scope.status !== 'ready')
|
|
333
310
|
.map((scope) => scope.repository_scope_id);
|
|
334
311
|
return {
|
|
335
312
|
revision: Number(project.topology_revision),
|
|
336
|
-
workspace_root:
|
|
313
|
+
workspace_root: null,
|
|
337
314
|
scopes,
|
|
338
315
|
complete: missingRequiredScopeIds.length === 0,
|
|
339
316
|
missing_required_scope_ids: missingRequiredScopeIds,
|
|
@@ -382,8 +359,8 @@ export async function beginAgentRunHandler(deps, session, args) {
|
|
|
382
359
|
'Call end_work before beginning a different task in the same agent session.');
|
|
383
360
|
}
|
|
384
361
|
const task = deps.projectId
|
|
385
|
-
? await must(deps.client.from('tasks').select('id,project_id,description').eq('id', args.task_id).eq('project_id', deps.projectId).maybeSingle())
|
|
386
|
-
: await must(deps.client.from('tasks').select('id,project_id,description').eq('id', args.task_id).maybeSingle());
|
|
362
|
+
? await must(deps.client.from('tasks').select('id,project_id,description').eq('id', args.task_id).eq('project_id', deps.projectId).is('archived_at', null).maybeSingle())
|
|
363
|
+
: await must(deps.client.from('tasks').select('id,project_id,description').eq('id', args.task_id).is('archived_at', null).maybeSingle());
|
|
387
364
|
if (!task)
|
|
388
365
|
return errorResult(`No accessible task found for id "${args.task_id}".`);
|
|
389
366
|
const taskProjectId = task.project_id ?? deps.projectId;
|
|
@@ -1114,7 +1091,7 @@ export async function listTasksHandler(deps, args = {}) {
|
|
|
1114
1091
|
const projectIds = args.project_id ? [args.project_id] : projects.map((project) => project.id);
|
|
1115
1092
|
if (projectIds.length === 0)
|
|
1116
1093
|
return textResult({ projects: [], tasks: [] });
|
|
1117
|
-
let query = deps.client.from('tasks').select(TASK_SELECT);
|
|
1094
|
+
let query = deps.client.from('tasks').select(TASK_SELECT).is('archived_at', null);
|
|
1118
1095
|
query = projectIds.length === 1
|
|
1119
1096
|
? query.eq('project_id', projectIds[0])
|
|
1120
1097
|
: query.in('project_id', projectIds);
|
|
@@ -1140,6 +1117,33 @@ export async function listTasksHandler(deps, args = {}) {
|
|
|
1140
1117
|
return errorResult(`list_tasks failed: ${err.message}`);
|
|
1141
1118
|
}
|
|
1142
1119
|
}
|
|
1120
|
+
export async function setTaskRoleSlugsHandler(deps, session, args) {
|
|
1121
|
+
const run = session.run;
|
|
1122
|
+
if (!run || run.state !== 'joined')
|
|
1123
|
+
return errorResult('set_task_role_slugs requires an active agent run.');
|
|
1124
|
+
if (run.role !== 'coordinator')
|
|
1125
|
+
return errorResult('Only the coordinator can set the folders this task needs.');
|
|
1126
|
+
if (args.task_id !== run.taskId)
|
|
1127
|
+
return errorResult(`set_task_role_slugs must stay on this agent run's task (${run.taskId}).`);
|
|
1128
|
+
try {
|
|
1129
|
+
const heartbeat = await heartbeatAgentRunHandler(deps, session);
|
|
1130
|
+
if (heartbeat.isError)
|
|
1131
|
+
return errorResult('set_task_role_slugs requires a current coordinator lease.');
|
|
1132
|
+
if (!session.run || session.run.state !== 'joined' || session.run.role !== 'coordinator') {
|
|
1133
|
+
return errorResult('set_task_role_slugs requires a current coordinator lease.');
|
|
1134
|
+
}
|
|
1135
|
+
const { data, error } = await deps.client.rpc('set_task_role_slugs', {
|
|
1136
|
+
p_task: args.task_id,
|
|
1137
|
+
p_role_slugs: args.role_slugs,
|
|
1138
|
+
});
|
|
1139
|
+
if (error)
|
|
1140
|
+
throw error;
|
|
1141
|
+
return textResult({ task_id: args.task_id, role_slugs: data ?? [] });
|
|
1142
|
+
}
|
|
1143
|
+
catch (error) {
|
|
1144
|
+
return errorResult(`set_task_role_slugs failed: ${error.message}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1143
1147
|
export async function getTaskHandler(deps, _attribution, args, machineId) {
|
|
1144
1148
|
try {
|
|
1145
1149
|
const { client } = deps;
|
|
@@ -1147,18 +1151,18 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
|
|
|
1147
1151
|
const allowedProjectIds = new Set(projects.map((project) => project.id));
|
|
1148
1152
|
let highlightArtifactId = null;
|
|
1149
1153
|
let row = deps.projectId
|
|
1150
|
-
? await must(client.from('tasks').select(TASK_SELECT).eq('id', args.id).eq('project_id', deps.projectId).maybeSingle())
|
|
1151
|
-
: await must(client.from('tasks').select(TASK_SELECT).eq('id', args.id).maybeSingle());
|
|
1154
|
+
? await must(client.from('tasks').select(TASK_SELECT).eq('id', args.id).eq('project_id', deps.projectId).is('archived_at', null).maybeSingle())
|
|
1155
|
+
: await must(client.from('tasks').select(TASK_SELECT).eq('id', args.id).is('archived_at', null).maybeSingle());
|
|
1152
1156
|
if (!row) {
|
|
1153
1157
|
// spec: get_task also accepts an artifact id (the `/ctrl-spc artifact
|
|
1154
1158
|
// <id>` path) — resolve to its parent task, highlighted first.
|
|
1155
|
-
const artifact = await must(client.from('artifacts').select('id,task_id').eq('id', args.id).maybeSingle());
|
|
1159
|
+
const artifact = await must(client.from('artifacts').select('id,task_id').eq('id', args.id).is('deleted_at', null).maybeSingle());
|
|
1156
1160
|
if (!artifact)
|
|
1157
1161
|
return errorResult(`No task or artifact found for id "${args.id}".`);
|
|
1158
1162
|
highlightArtifactId = artifact.id;
|
|
1159
1163
|
row = deps.projectId
|
|
1160
|
-
? await must(client.from('tasks').select(TASK_SELECT).eq('id', artifact.task_id).eq('project_id', deps.projectId).maybeSingle())
|
|
1161
|
-
: await must(client.from('tasks').select(TASK_SELECT).eq('id', artifact.task_id).maybeSingle());
|
|
1164
|
+
? await must(client.from('tasks').select(TASK_SELECT).eq('id', artifact.task_id).eq('project_id', deps.projectId).is('archived_at', null).maybeSingle())
|
|
1165
|
+
: await must(client.from('tasks').select(TASK_SELECT).eq('id', artifact.task_id).is('archived_at', null).maybeSingle());
|
|
1162
1166
|
if (!row)
|
|
1163
1167
|
return errorResult(`No task or artifact found for id "${args.id}".`);
|
|
1164
1168
|
}
|
|
@@ -1212,7 +1216,7 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
|
|
|
1212
1216
|
.eq('active', true)
|
|
1213
1217
|
.maybeSingle()),
|
|
1214
1218
|
topologyMachineId
|
|
1215
|
-
? loadAgentProjectTopology(client, row.project_id, deps.
|
|
1219
|
+
? loadAgentProjectTopology(client, row.project_id, topologyMachineId, deps.statPath ?? statSync)
|
|
1216
1220
|
: Promise.resolve(undefined),
|
|
1217
1221
|
loadWorkflowContext(client, row.id),
|
|
1218
1222
|
]);
|
|
@@ -1293,7 +1297,7 @@ export async function createTaskHandler(deps, _attribution, args) {
|
|
|
1293
1297
|
p_name: args.name,
|
|
1294
1298
|
p_description: args.description ?? '',
|
|
1295
1299
|
p_owner: userId,
|
|
1296
|
-
p_use_workflow:
|
|
1300
|
+
p_use_workflow: false,
|
|
1297
1301
|
p_workflow_version: null,
|
|
1298
1302
|
}));
|
|
1299
1303
|
if (!taskId)
|
|
@@ -1307,7 +1311,7 @@ export async function createTaskHandler(deps, _attribution, args) {
|
|
|
1307
1311
|
if (args.before_task_id) {
|
|
1308
1312
|
await must(client.rpc('move_task', { p_task_id: taskId, p_status: 'backlog', p_before_task_id: args.before_task_id }));
|
|
1309
1313
|
}
|
|
1310
|
-
const fresh = await must(client.from('tasks').select(TASK_SELECT).eq('id', taskId).maybeSingle());
|
|
1314
|
+
const fresh = await must(client.from('tasks').select(TASK_SELECT).eq('id', taskId).is('archived_at', null).maybeSingle());
|
|
1311
1315
|
if (!fresh)
|
|
1312
1316
|
throw new Error('Created task could not be re-fetched.');
|
|
1313
1317
|
return textResult({ task: parseTaskRow(fresh) });
|
|
@@ -1342,7 +1346,7 @@ export async function updateTaskHandler(deps, _attribution, args, agentRun) {
|
|
|
1342
1346
|
if (fields.sprint !== undefined && fields.sprint !== null && fields.sprint.trim() === '') {
|
|
1343
1347
|
return errorResult('update_task: sprint name cannot be empty or whitespace-only.');
|
|
1344
1348
|
}
|
|
1345
|
-
const current = await must(client.from('tasks').select('id,status,revision').eq('id', id).eq('project_id', projectId).maybeSingle());
|
|
1349
|
+
const current = await must(client.from('tasks').select('id,status,revision').eq('id', id).eq('project_id', projectId).is('archived_at', null).maybeSingle());
|
|
1346
1350
|
if (!current)
|
|
1347
1351
|
return errorResult(`No task found for id "${id}" in this project.`);
|
|
1348
1352
|
const patch = {};
|
|
@@ -1384,7 +1388,7 @@ export async function updateTaskHandler(deps, _attribution, args, agentRun) {
|
|
|
1384
1388
|
p_reorder: statusChanged || fields.before_task_id !== undefined,
|
|
1385
1389
|
p_before_task_id: fields.before_task_id ?? null,
|
|
1386
1390
|
}));
|
|
1387
|
-
const fresh = await must(client.from('tasks').select(TASK_SELECT).eq('id', id).maybeSingle());
|
|
1391
|
+
const fresh = await must(client.from('tasks').select(TASK_SELECT).eq('id', id).is('archived_at', null).maybeSingle());
|
|
1388
1392
|
if (!fresh)
|
|
1389
1393
|
throw new Error('Updated task could not be re-fetched.');
|
|
1390
1394
|
return textResult({ task: parseTaskRow(fresh) });
|
|
@@ -1445,7 +1449,7 @@ export async function createArtifactHandler(deps, attribution, args, agentRun) {
|
|
|
1445
1449
|
if (attribution) {
|
|
1446
1450
|
return errorResult('Agent artifacts require a joined agent run and complete repository coverage.');
|
|
1447
1451
|
}
|
|
1448
|
-
const task = await must(deps.client.from('tasks').select('id').eq('id', args.task_id)
|
|
1452
|
+
const task = await must(deps.client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null)
|
|
1449
1453
|
.eq('project_id', requiredProjectId(deps, 'create_artifact')).maybeSingle());
|
|
1450
1454
|
if (!task)
|
|
1451
1455
|
return errorResult(`No task found for id "${args.task_id}" in this project.`);
|
|
@@ -1478,7 +1482,7 @@ export async function addCommentHandler(deps, attribution, args, agentRunId) {
|
|
|
1478
1482
|
return errorResult('add_comment requires task_id.');
|
|
1479
1483
|
if (!args.body || !args.body.trim())
|
|
1480
1484
|
return errorResult('add_comment requires a non-empty body.');
|
|
1481
|
-
const task = await must(deps.client.from('tasks').select('id').eq('id', args.task_id)
|
|
1485
|
+
const task = await must(deps.client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null)
|
|
1482
1486
|
.eq('project_id', requiredProjectId(deps, 'add_comment')).maybeSingle());
|
|
1483
1487
|
if (!task)
|
|
1484
1488
|
return errorResult(`No task found for id "${args.task_id}" in this project.`);
|
|
@@ -1527,6 +1531,10 @@ async function readJsonBody(req) {
|
|
|
1527
1531
|
return raw ? JSON.parse(raw) : undefined;
|
|
1528
1532
|
}
|
|
1529
1533
|
export async function startMcpServer(deps) {
|
|
1534
|
+
const capabilities = deps.capabilities ?? await fetchServerCapabilities(deps.client);
|
|
1535
|
+
if (!Boolean(capabilities.root_roles)) {
|
|
1536
|
+
throw new Error("This server doesn't support folder labels yet — update the server before brokering.");
|
|
1537
|
+
}
|
|
1530
1538
|
const port = deps.port ?? MCP_PORT;
|
|
1531
1539
|
const machineId = deps.machineId ?? getMachineIdentity().id;
|
|
1532
1540
|
const handlerDeps = {
|
|
@@ -1684,6 +1692,16 @@ export async function startMcpServer(deps) {
|
|
|
1684
1692
|
resetSessionIdleTimer(session);
|
|
1685
1693
|
return heartbeatAgentRunHandler(handlerDeps, session.context);
|
|
1686
1694
|
});
|
|
1695
|
+
server.registerTool('set_task_role_slugs', {
|
|
1696
|
+
description: 'Set the folder labels this task needs. Unknown labels are ignored by the server.',
|
|
1697
|
+
inputSchema: {
|
|
1698
|
+
task_id: z.string(),
|
|
1699
|
+
role_slugs: z.array(z.string()),
|
|
1700
|
+
},
|
|
1701
|
+
}, async (args) => {
|
|
1702
|
+
resetSessionIdleTimer(session);
|
|
1703
|
+
return setTaskRoleSlugsHandler(handlerDeps, session.context, args);
|
|
1704
|
+
});
|
|
1687
1705
|
server.registerTool('reserve_work_paths', {
|
|
1688
1706
|
description: 'Atomically reserve repository-relative paths before editing. Write reservations require the exact repository_checkout_id from get_task topology.checkouts, including when selecting an isolated worktree.',
|
|
1689
1707
|
inputSchema: {
|
|
@@ -1968,7 +1986,16 @@ export async function startMcpServer(deps) {
|
|
|
1968
1986
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1969
1987
|
if (url.pathname === '/health' && req.method === 'GET') {
|
|
1970
1988
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1971
|
-
res.end(JSON.stringify({
|
|
1989
|
+
res.end(JSON.stringify({
|
|
1990
|
+
status: 'ok',
|
|
1991
|
+
machine_id: machineId,
|
|
1992
|
+
version: BROKER_VERSION,
|
|
1993
|
+
process_id: process.pid,
|
|
1994
|
+
runtime: deps.runtime ?? {
|
|
1995
|
+
kind: process.env.CTRL_SPC_RUNTIME_KIND || 'unlabeled',
|
|
1996
|
+
label: process.env.CTRL_SPC_RUNTIME_LABEL || 'Unlabeled connection',
|
|
1997
|
+
},
|
|
1998
|
+
}));
|
|
1972
1999
|
return;
|
|
1973
2000
|
}
|
|
1974
2001
|
if (url.pathname !== '/mcp') {
|
package/dist/relink.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function resolveRelinkRoots(roots, candidates) {
|
|
2
|
+
const used = new Set();
|
|
3
|
+
return roots.map(root => {
|
|
4
|
+
const remote = candidates.find(candidate => !used.has(candidate.path) && candidate.remoteKey === root.remoteKey);
|
|
5
|
+
if (remote) {
|
|
6
|
+
used.add(remote.path);
|
|
7
|
+
return { root, candidate: remote, rung: 'remote' };
|
|
8
|
+
}
|
|
9
|
+
const identity = candidates.find(candidate => !used.has(candidate.path) && candidate.stampedIdentityKey !== null && (candidate.stampedIdentityKey === root.identityKey || candidate.stampedIdentityKey === root.priorIdentityKey));
|
|
10
|
+
if (identity) {
|
|
11
|
+
used.add(identity.path);
|
|
12
|
+
return { root, candidate: identity, rung: 'identity' };
|
|
13
|
+
}
|
|
14
|
+
const shaMatches = candidates.filter(candidate => (!used.has(candidate.path) && root.rootCommitSha !== null && candidate.rootCommitSha === root.rootCommitSha));
|
|
15
|
+
if (shaMatches.length === 1) {
|
|
16
|
+
used.add(shaMatches[0].path);
|
|
17
|
+
return { root, candidate: shaMatches[0], rung: 'root_commit' };
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
root,
|
|
21
|
+
candidate: null,
|
|
22
|
+
rung: 'manual',
|
|
23
|
+
manualReason: shaMatches.length > 1 || candidates.some(candidate => (!used.has(candidate.path) && !roots.some(known => (candidate.remoteKey === known.remoteKey
|
|
24
|
+
|| candidate.stampedIdentityKey === known.identityKey
|
|
25
|
+
|| candidate.stampedIdentityKey === known.priorIdentityKey
|
|
26
|
+
|| (candidate.rootCommitSha !== null && candidate.rootCommitSha === known.rootCommitSha)))))
|
|
27
|
+
? 'ambiguous'
|
|
28
|
+
: 'not_found',
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export async function resolveProjectRootRows(client, projectId, machineId) {
|
|
2
|
+
const { data, error } = await client.rpc('resolve_project_roots', {
|
|
3
|
+
p_project_id: projectId,
|
|
4
|
+
p_machine_id: machineId,
|
|
5
|
+
});
|
|
6
|
+
if (error)
|
|
7
|
+
throw new Error(`Project folders could not be resolved: ${error.message}`);
|
|
8
|
+
return (data ?? []);
|
|
9
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { ROLE_SUGGESTIONS } from './roles.js';
|
|
4
|
+
const labelFor = (slug) => ROLE_SUGGESTIONS.find(role => role.slug === slug).label;
|
|
5
|
+
const fileText = (path) => {
|
|
6
|
+
try {
|
|
7
|
+
return readFileSync(path, 'utf8');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const rootEntries = (dir) => {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const hit = (slug, evidence) => ({ slug, label: labelFor(slug), evidence });
|
|
22
|
+
const ANDROID_SCAN_SKIPS = new Set(['.git', 'build', 'dist', 'node_modules', 'target', 'vendor']);
|
|
23
|
+
function findWithin(dir, name, depth, budget = { remaining: 256 }) {
|
|
24
|
+
if (depth < 0 || budget.remaining <= 0)
|
|
25
|
+
return null;
|
|
26
|
+
for (const entry of rootEntries(dir)) {
|
|
27
|
+
if (--budget.remaining < 0)
|
|
28
|
+
return null;
|
|
29
|
+
const path = join(dir, entry.name);
|
|
30
|
+
if (entry.isFile() && entry.name === name)
|
|
31
|
+
return path;
|
|
32
|
+
if (entry.isDirectory() && depth > 0 && !ANDROID_SCAN_SKIPS.has(entry.name)) {
|
|
33
|
+
const nested = findWithin(path, name, depth - 1, budget);
|
|
34
|
+
if (nested)
|
|
35
|
+
return nested;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function packageJson(dir) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function detectRoleFingerprint(dir) {
|
|
49
|
+
const entries = rootEntries(dir);
|
|
50
|
+
const names = new Set(entries.map(entry => entry.name));
|
|
51
|
+
const nameEvidence = basename(dir) ? [`folder:${basename(dir)}`] : [];
|
|
52
|
+
const apple = entries.find(entry => entry.name.endsWith('.xcodeproj') || entry.name.endsWith('.xcworkspace'));
|
|
53
|
+
if (apple)
|
|
54
|
+
return hit('ios-app', [`entry:${apple.name}`, ...nameEvidence]);
|
|
55
|
+
const gradle = ['build.gradle', 'build.gradle.kts'].find(name => names.has(name));
|
|
56
|
+
const manifest = gradle ? findWithin(dir, 'AndroidManifest.xml', 3) : null;
|
|
57
|
+
if (gradle && manifest)
|
|
58
|
+
return hit('android-app', [`file:${gradle}`, `file:${manifest}`, ...nameEvidence]);
|
|
59
|
+
if (names.has('pubspec.yaml') && /flutter/i.test(fileText(join(dir, 'pubspec.yaml')))) {
|
|
60
|
+
return hit('mobile-app', ['file:pubspec.yaml', ...nameEvidence]);
|
|
61
|
+
}
|
|
62
|
+
const pkg = packageJson(dir);
|
|
63
|
+
const packageDependencies = pkg?.dependencies ?? {};
|
|
64
|
+
const dependencies = {
|
|
65
|
+
...packageDependencies,
|
|
66
|
+
...(pkg?.devDependencies ?? {}),
|
|
67
|
+
};
|
|
68
|
+
const webDependency = ['react', 'next', 'vite'].find(name => name in dependencies);
|
|
69
|
+
if (webDependency)
|
|
70
|
+
return hit('web-app', [`package:${webDependency}`, ...nameEvidence]);
|
|
71
|
+
if ('express' in packageDependencies)
|
|
72
|
+
return hit('api', ['package:express', ...nameEvidence]);
|
|
73
|
+
if (names.has('Gemfile') && /rails/i.test(fileText(join(dir, 'Gemfile'))))
|
|
74
|
+
return hit('api', ['file:Gemfile', ...nameEvidence]);
|
|
75
|
+
if (names.has('manage.py'))
|
|
76
|
+
return hit('api', ['file:manage.py', ...nameEvidence]);
|
|
77
|
+
if ((names.has('Cargo.toml') && /\[\[bin\]\]/.test(fileText(join(dir, 'Cargo.toml')))) || existsSync(join(dir, 'src', 'main.rs'))) {
|
|
78
|
+
return hit('cli', [names.has('Cargo.toml') ? 'file:Cargo.toml' : 'file:src/main.rs', ...nameEvidence]);
|
|
79
|
+
}
|
|
80
|
+
if (pkg && pkg.bin !== undefined)
|
|
81
|
+
return hit('cli', ['package:bin', ...nameEvidence]);
|
|
82
|
+
if (existsSync(join(dir, 'prisma', 'schema.prisma')))
|
|
83
|
+
return hit('schema', ['file:prisma/schema.prisma', ...nameEvidence]);
|
|
84
|
+
const migrationEntries = rootEntries(join(dir, 'migrations')).filter(entry => entry.isFile());
|
|
85
|
+
if (migrationEntries.length > 0 && migrationEntries.filter(entry => entry.name.endsWith('.sql')).length / migrationEntries.length >= 0.5) {
|
|
86
|
+
return hit('schema', ['directory:migrations', ...nameEvidence]);
|
|
87
|
+
}
|
|
88
|
+
const files = entries.filter(entry => entry.isFile());
|
|
89
|
+
if (files.length > 0 && files.filter(entry => entry.name.endsWith('.sql')).length / files.length >= 0.5) {
|
|
90
|
+
return hit('schema', ['root:sql-majority', ...nameEvidence]);
|
|
91
|
+
}
|
|
92
|
+
if (names.has('Dockerfile'))
|
|
93
|
+
return hit('infra', ['file:Dockerfile', ...nameEvidence]);
|
|
94
|
+
const terraform = entries.find(entry => entry.isFile() && entry.name.endsWith('.tf'));
|
|
95
|
+
if (terraform)
|
|
96
|
+
return hit('infra', [`file:${terraform.name}`, ...nameEvidence]);
|
|
97
|
+
if (files.length > 0 && files.filter(entry => entry.name.toLowerCase().endsWith('.md')).length / files.length >= 0.6) {
|
|
98
|
+
return hit('docs', ['root:markdown-majority', ...nameEvidence]);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|