@ctrl-spc/cli 1.3.2 → 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/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, userId, machineId) {
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
- const scopeRows = (await must(client
256
- .from('project_repository_scopes')
257
- .select('repository_scope_id,purpose,required,position,' +
258
- 'scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
259
- 'id,repository_id,scope_path,kind,name,' +
260
- 'repository:repositories!repository_scopes_repository_id_fkey(id,remote_key,name))')
261
- .eq('project_id', projectId)
262
- .eq('active', true)
263
- .order('position', { ascending: true }))) ?? [];
264
- const workspace = await must(client
265
- .from('machine_workspaces')
266
- .select('id,workspace_root')
267
- .eq('user_id', userId)
268
- .eq('machine_id', machineId)
269
- .eq('project_id', projectId)
270
- .maybeSingle());
271
- const checkouts = workspace
272
- ? (await must(client
273
- .from('repository_checkouts')
274
- .select('id,repository_id,local_path,availability,verified_remote_key,head_sha,is_worktree,verified_at')
275
- .eq('machine_workspace_id', workspace.id))) ?? []
276
- : [];
277
- const checkoutsByRepository = new Map();
278
- for (const checkout of checkouts) {
279
- const existing = checkoutsByRepository.get(checkout.repository_id) ?? [];
280
- existing.push(checkout);
281
- checkoutsByRepository.set(checkout.repository_id, existing);
282
- }
283
- const scopes = scopeRows.flatMap((row) => {
284
- const scope = row.scope;
285
- if (!scope?.repository)
286
- return [];
287
- const candidates = [...(checkoutsByRepository.get(scope.repository_id) ?? [])]
288
- .sort((left, right) => Number(right.availability === 'available') - Number(left.availability === 'available') ||
289
- left.local_path.localeCompare(right.local_path) || left.id.localeCompare(right.id));
290
- const checkout = candidates.find((candidate) => candidate.availability === 'available') ?? candidates[0] ?? null;
291
- const availability = checkout?.availability ?? 'missing';
292
- return [{
293
- repository_scope_id: scope.id,
294
- repository_id: scope.repository.id,
295
- repository_name: scope.repository.name,
296
- remote_key: scope.repository.remote_key,
297
- scope_name: scope.name,
298
- scope_path: scope.scope_path,
299
- scope_kind: scope.kind,
300
- purpose: row.purpose,
301
- required: row.required,
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.checkout.availability !== 'available')
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: workspace?.workspace_root ?? null,
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.userId, topologyMachineId)
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: true,
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({ status: 'ok', machine_id: machineId, version: BROKER_VERSION }));
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
+ }
@@ -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
+ }
package/dist/roles.js ADDED
@@ -0,0 +1,18 @@
1
+ export const ROLE_SUGGESTIONS = [
2
+ { slug: 'ios-app', label: 'iOS app' },
3
+ { slug: 'android-app', label: 'Android app' },
4
+ { slug: 'mobile-app', label: 'Mobile app' },
5
+ { slug: 'web-app', label: 'Web app' },
6
+ { slug: 'api', label: 'API server' },
7
+ { slug: 'cli', label: 'Command-line tool' },
8
+ { slug: 'schema', label: 'Database & schema' },
9
+ { slug: 'infra', label: 'Infrastructure' },
10
+ { slug: 'docs', label: 'Docs' },
11
+ ];
12
+ export function isValidRoleSlug(value) {
13
+ return /^[a-z0-9][a-z0-9-]{0,39}$/.test(value);
14
+ }
15
+ export function suffixedRoleSlug(base, number) {
16
+ const suffix = `-${number}`;
17
+ return `${base.slice(0, 40 - suffix.length)}${suffix}`;
18
+ }