@piagent/platform 1.1.5 → 1.1.7

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.
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // A project profile can either state its whole policy or name the adapter it
5
+ // takes that policy from.
6
+ //
7
+ // Stating the whole policy freezes it. The adapters ship with the platform and
8
+ // are corrected by it, but a project that copied one at onboarding time keeps
9
+ // the copy forever, so a global update leaves every existing project on the old
10
+ // rules. `extends` makes the policy a reference instead: the project keeps the
11
+ // parts that are about the project, and the installed platform supplies the
12
+ // rest.
13
+ //
14
+ // Profiles written before this existed carry no `extends` and keep working
15
+ // exactly as they did, self-contained.
16
+
17
+ const ADAPTER_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
18
+
19
+ export class ProjectProfileError extends Error {}
20
+
21
+ export function adapterProfileFile(platformRoot, name) {
22
+ if (typeof name !== "string") return undefined;
23
+ const trimmed = name.trim();
24
+ // The name indexes a directory inside the installed platform, so it is
25
+ // restricted to a plain slug rather than sanitised after the fact.
26
+ if (!ADAPTER_NAME.test(trimmed)) return undefined;
27
+ const candidate = path.join(platformRoot, "adapters", trimmed, "profile.json");
28
+ return fs.existsSync(candidate) ? candidate : undefined;
29
+ }
30
+
31
+ function readJson(file) {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(file, "utf8"));
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Resolve a stored project profile document against the installed adapters.
41
+ * Returns the document to enforce, plus the adapter it came from when one was
42
+ * named. Any key the project states replaces the adapter's key whole — there is
43
+ * no per-entry merging of lists, because a half-merged protectedPaths is harder
44
+ * to reason about than a stated one.
45
+ */
46
+ export function resolveProjectProfileDocument(platformRoot, stored) {
47
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
48
+ return { profile: stored, base: undefined };
49
+ }
50
+ if (stored.extends === undefined) return { profile: stored, base: undefined };
51
+
52
+ const file = adapterProfileFile(platformRoot, stored.extends);
53
+ if (!file) {
54
+ throw new ProjectProfileError(
55
+ `project profile extends "${String(stored.extends)}", which is not an adapter in the installed platform`
56
+ );
57
+ }
58
+ const adapter = readJson(file);
59
+ if (!adapter) throw new ProjectProfileError(`adapter profile is unreadable: ${file}`);
60
+
61
+ const { extends: base, ...overrides } = stored;
62
+ return { profile: { ...adapter, ...overrides }, base };
63
+ }
64
+
65
+ export function readProjectProfileDocument(platformRoot, profilePath) {
66
+ const stored = readJson(profilePath);
67
+ if (stored === undefined) return { stored: undefined, profile: undefined, base: undefined };
68
+ const { profile, base } = resolveProjectProfileDocument(platformRoot, stored);
69
+ return { stored, profile, base };
70
+ }
71
+
72
+ /**
73
+ * The document a project stores when it takes its policy from an adapter: what
74
+ * identifies this project, and nothing the platform already knows.
75
+ */
76
+ export function buildExtendingProfile(base, projectId, displayName) {
77
+ return {
78
+ schemaVersion: 1,
79
+ extends: base,
80
+ projectId,
81
+ displayName
82
+ };
83
+ }
@@ -39,9 +39,12 @@ import {
39
39
  redactForStorage,
40
40
  redactSensitiveText
41
41
  } from "./redaction-core.js";
42
+ import { detectProfileName } from "./project-shape.js";
43
+ import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
42
44
  import {
43
45
  resolveCapabilityProfileDocument,
44
46
  verifyCapabilityLock,
47
+ writeJsonAtomic,
45
48
  writeProfileLockAtomic
46
49
  } from "../capabilities/capability-core.js";
47
50
  import { resolveCapabilitySourceRoots } from "../capabilities/capability-sources.js";
@@ -416,6 +419,10 @@ const DEFAULT_POLICY: BasePolicy = {
416
419
  orchestrationPolicy: DEFAULT_ORCHESTRATION_POLICY
417
420
  };
418
421
 
422
+ // Which platform supplies the adapters is fixed by where this file is installed,
423
+ // so it is resolved once here rather than threaded through every profile load.
424
+ const PLATFORM_ROOT = findPlatformRoot(path.dirname(fileURLToPath(import.meta.url)));
425
+
419
426
  function loadPolicy(extensionDir: string): BasePolicy {
420
427
  const root = findPackageRoot(extensionDir);
421
428
  return readJsonFile<BasePolicy>(path.join(root, "policies", "base-policy.json")) ?? DEFAULT_POLICY;
@@ -432,17 +439,38 @@ function fallbackProfile(cwd: string, mode = "unprofiled"): ProjectProfile {
432
439
  };
433
440
  }
434
441
 
442
+ // A profile that names an adapter this platform does not have cannot be
443
+ // enforced, and the unprofiled shape carries no readOnlyPaths — falling back to
444
+ // it would quietly hand write access to whatever the profile was protecting. The
445
+ // session stays readable and says why instead.
446
+ function unresolvedProfile(cwd: string, reason: string): ProjectProfile {
447
+ return {
448
+ ...fallbackProfile(cwd, "unresolved-profile-base"),
449
+ permissionProfile: "read-only",
450
+ unresolvedReason: reason
451
+ } as ProjectProfile;
452
+ }
453
+
454
+ function resolveStoredProfile(stored: ProjectProfile | undefined, cwd: string, missingMode: string): ProjectProfile {
455
+ if (!stored) return fallbackProfile(cwd, missingMode);
456
+ try {
457
+ return resolveProjectProfileDocument(PLATFORM_ROOT, stored).profile as ProjectProfile;
458
+ } catch (error) {
459
+ return unresolvedProfile(cwd, error instanceof Error ? error.message : String(error));
460
+ }
461
+ }
462
+
435
463
  function loadProfile(cwd: string, projectTrusted = false): ProjectProfile {
436
464
  const explicit = process.env.PIAGENT_PROFILE;
437
465
  if (explicit && explicit.trim().length > 0) {
438
- return readJsonFile<ProjectProfile>(explicit) ?? fallbackProfile(cwd, "explicit-profile-unreadable");
466
+ return resolveStoredProfile(readJsonFile<ProjectProfile>(explicit), cwd, "explicit-profile-unreadable");
439
467
  }
440
468
 
441
469
  if (!projectTrusted) {
442
470
  return fallbackProfile(cwd, "unprofiled-global-package");
443
471
  }
444
472
 
445
- return readJsonFile<ProjectProfile>(path.join(cwd, ".pi", "piagent-profile.json")) ?? fallbackProfile(cwd, projectTrusted ? "unprofiled" : "unprofiled-global-package");
473
+ return resolveStoredProfile(readJsonFile<ProjectProfile>(path.join(cwd, ".pi", "piagent-profile.json")), cwd, "unprofiled");
446
474
  }
447
475
 
448
476
  function loadProfileFromContext(ctx: ExtensionContext): ProjectProfile {
@@ -529,6 +557,7 @@ function capabilitySourceRoots(
529
557
  type ProjectCapabilityState = {
530
558
  ok: boolean;
531
559
  reason?: string;
560
+ repinned?: string;
532
561
  filesystemRead?: string[];
533
562
  filesystemWrite?: string[];
534
563
  };
@@ -538,8 +567,18 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
538
567
  if (!projectTrusted) return { ok: true };
539
568
  const profilePath = projectProfilePath(cwd);
540
569
  if (!fs.existsSync(profilePath)) return { ok: true };
541
- const profile = readJsonFile<ProjectProfile>(profilePath);
542
- if (!profile || !Array.isArray(profile.capabilityPacks)) return { ok: true };
570
+ // Which packs a project selects can come from the adapter it extends, so the
571
+ // question has to be asked of the resolved document. Asking the stored one
572
+ // would skip the lock entirely for every project that references an adapter.
573
+ const stored = readJsonFile<ProjectProfile>(profilePath);
574
+ if (!stored) return { ok: true };
575
+ let profile: ProjectProfile;
576
+ try {
577
+ profile = resolveProjectProfileDocument(PLATFORM_ROOT, stored).profile as ProjectProfile;
578
+ } catch (error) {
579
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
580
+ }
581
+ if (!Array.isArray(profile.capabilityPacks)) return { ok: true };
543
582
  const lockPath = path.join(cwd, ".pi", "piagent-profile.lock.json");
544
583
  if (!fs.existsSync(lockPath)) return { ok: false, reason: "Capability lock is missing. Reapply the project profile." };
545
584
  const lock = readJsonFile<Record<string, unknown>>(lockPath);
@@ -549,13 +588,30 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
549
588
  packageSource: projectPackageSource(cwd),
550
589
  extraRoots: capabilitySourceRoots(cwd, profile)
551
590
  });
552
- return verification.ok
553
- ? {
554
- ok: true,
555
- filesystemRead: verification.expected.permissions.filesystemRead,
556
- filesystemWrite: verification.expected.permissions.filesystemWrite
557
- }
558
- : { ok: false, reason: "Capability lock does not match the active profile, package source, or installed platform." };
591
+ if (verification.status === "blocked") {
592
+ return {
593
+ ok: false,
594
+ reason: `Capability lock does not match what this project agreed to: ${verification.reasons.join("; ")}. Reapply the project profile.`
595
+ };
596
+ }
597
+ const granted = {
598
+ filesystemRead: verification.expected.permissions.filesystemRead,
599
+ filesystemWrite: verification.expected.permissions.filesystemWrite
600
+ };
601
+ if (verification.status !== "repin") return { ok: true, ...granted };
602
+
603
+ // The grant is unchanged and only the platform build moved. Record the new
604
+ // build instead of stopping the session, which is what turned every release
605
+ // into a chore in every project.
606
+ try {
607
+ writeJsonAtomic(lockPath, verification.expected);
608
+ } catch (error) {
609
+ return {
610
+ ok: false,
611
+ reason: `Capability lock is behind the installed platform and could not be rewritten: ${error instanceof Error ? error.message : String(error)}`
612
+ };
613
+ }
614
+ return { ok: true, repinned: verification.reasons.join("; "), ...granted };
559
615
  } catch (error) {
560
616
  return { ok: false, reason: `Capability validation failed: ${error instanceof Error ? error.message : String(error)}` };
561
617
  }
@@ -2636,75 +2692,6 @@ function readAdapterProfiles(extensionDir: string): Array<{ name: string; profil
2636
2692
  .sort((a, b) => a.name.localeCompare(b.name));
2637
2693
  }
2638
2694
 
2639
- function packageHasAny(cwd: string, pattern: RegExp): boolean {
2640
- try {
2641
- return pattern.test(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
2642
- } catch {
2643
- return false;
2644
- }
2645
- }
2646
-
2647
- function detectProfileName(cwd: string, intent?: string): { name: string; reason: string } {
2648
- const normalizedIntent = intent?.trim().toLowerCase();
2649
- if (normalizedIntent === "be-readonly-fe") return { name: "be-readonly-fe", reason: "User intent says backend should be read-only while frontend is the write target." };
2650
- if (normalizedIntent === "frontend-only") return { name: "web-frontend", reason: "User intent says frontend-only." };
2651
- if (normalizedIntent === "backend-only") return { name: "backend-api", reason: "User intent says backend-only." };
2652
- if (normalizedIntent === "docs") return { name: "docs", reason: "User intent says docs/docs-only." };
2653
-
2654
- const hasPackage = fs.existsSync(path.join(cwd, "package.json"));
2655
- let frontend = false;
2656
- let backend = false;
2657
- let data = false;
2658
- let mobile = false;
2659
- let infra = false;
2660
- let docs = false;
2661
-
2662
- if (fs.existsSync(path.join(cwd, "pubspec.yaml")) || (fs.existsSync(path.join(cwd, "android")) && fs.existsSync(path.join(cwd, "ios")))) mobile = true;
2663
- if (fs.existsSync(path.join(cwd, "dbt_project.yml")) || fs.existsSync(path.join(cwd, "dvc.yaml")) || fs.existsSync(path.join(cwd, "notebooks")) || fs.existsSync(path.join(cwd, "data"))) data = true;
2664
- if (fs.existsSync(path.join(cwd, "Dockerfile")) || fs.existsSync(path.join(cwd, "docker-compose.yml")) || fs.existsSync(path.join(cwd, "compose.yml")) || fs.existsSync(path.join(cwd, "compose.yaml")) || fs.existsSync(path.join(cwd, "terraform")) || fs.existsSync(path.join(cwd, "infra")) || fs.existsSync(path.join(cwd, "k8s")) || fs.existsSync(path.join(cwd, "helm"))) infra = true;
2665
- if (fs.existsSync(path.join(cwd, "docs")) || fs.existsSync(path.join(cwd, "mkdocs.yml")) || fs.existsSync(path.join(cwd, "mint.json")) || fs.existsSync(path.join(cwd, "docusaurus.config.js"))) docs = true;
2666
-
2667
- if (hasPackage) {
2668
- if (fs.existsSync(path.join(cwd, "frontend")) || fs.existsSync(path.join(cwd, "apps/web")) || fs.existsSync(path.join(cwd, "apps/frontend"))) frontend = true;
2669
- if (fs.existsSync(path.join(cwd, "backend")) || fs.existsSync(path.join(cwd, "apps/api")) || fs.existsSync(path.join(cwd, "apps/server"))) backend = true;
2670
- if (packageHasAny(cwd, /"(next|react|vite|vue|svelte|astro|@angular\/core|remix)"/i)
2671
- || fs.existsSync(path.join(cwd, "next.config.js"))
2672
- || fs.existsSync(path.join(cwd, "next.config.mjs"))
2673
- || fs.existsSync(path.join(cwd, "next.config.ts"))
2674
- || fs.existsSync(path.join(cwd, "vite.config.js"))
2675
- || fs.existsSync(path.join(cwd, "vite.config.ts"))
2676
- || fs.existsSync(path.join(cwd, "src/app"))
2677
- || fs.existsSync(path.join(cwd, "pages"))
2678
- || fs.existsSync(path.join(cwd, "public"))) frontend = true;
2679
- if (packageHasAny(cwd, /"(@nestjs|express|fastify|hono|koa|apollo-server|graphql-yoga|prisma|typeorm|sequelize|drizzle-orm)"/i)
2680
- || fs.existsSync(path.join(cwd, "nest-cli.json"))
2681
- || fs.existsSync(path.join(cwd, "prisma"))
2682
- || fs.existsSync(path.join(cwd, "src/server"))
2683
- || fs.existsSync(path.join(cwd, "src/api"))) backend = true;
2684
- }
2685
-
2686
- if (fs.existsSync(path.join(cwd, "pom.xml")) || fs.existsSync(path.join(cwd, "build.gradle")) || fs.existsSync(path.join(cwd, "build.gradle.kts")) || fs.existsSync(path.join(cwd, "src/main/java")) || fs.existsSync(path.join(cwd, "src/main/kotlin"))) backend = true;
2687
-
2688
- if (fs.existsSync(path.join(cwd, "pyproject.toml"))) {
2689
- try {
2690
- if (/(fastapi|flask|django|litestar|starlite)/i.test(fs.readFileSync(path.join(cwd, "pyproject.toml"), "utf8"))) backend = true;
2691
- } catch {
2692
- // ignore unreadable pyproject for recommendation
2693
- }
2694
- }
2695
-
2696
- if (mobile) return { name: "mobile", reason: "Mobile markers found." };
2697
- if (frontend && backend) return { name: "fullstack", reason: "Frontend and backend markers both found. Pick be-readonly-fe instead if backend must be read-only." };
2698
- if (frontend) return { name: "web-frontend", reason: "Frontend framework markers found." };
2699
- if (backend) return { name: "backend-api", reason: "Backend/API markers found." };
2700
- if (data) return { name: "data", reason: "Data/ETL markers found." };
2701
- if (fs.existsSync(path.join(cwd, "pyproject.toml"))) return { name: "python", reason: "Python pyproject.toml found." };
2702
- if (hasPackage && fs.existsSync(path.join(cwd, "tsconfig.json"))) return { name: "node-typescript", reason: "Node TypeScript markers found." };
2703
- if (infra) return { name: "devops", reason: "Infrastructure markers found." };
2704
- if (docs) return { name: "docs", reason: "Documentation markers found." };
2705
- return { name: "generic", reason: "No stronger project markers found." };
2706
- }
2707
-
2708
2695
  function profileDescription(name: string): string {
2709
2696
  const descriptions: Record<string, string> = {
2710
2697
  generic: "Safe baseline for unknown repos.",
@@ -2922,12 +2909,18 @@ function buildTechStackManifest(profileName: string, selectedOptions: TechOption
2922
2909
  };
2923
2910
  }
2924
2911
 
2912
+ // `profile` is the document the project stores. When it names an adapter, the
2913
+ // lock is resolved against that adapter but still records the stored document,
2914
+ // so a later platform correction is not a lock the project has to re-agree to.
2925
2915
  function writeProfileDocumentWithLock(extensionDir: string, cwd: string, profile: ProjectProfile): ProjectProfile {
2926
2916
  const target = projectProfilePath(cwd);
2927
- const capabilityLock = resolveCapabilityProfileDocument(findPlatformRoot(extensionDir), profile, {
2917
+ const platformRoot = findPlatformRoot(extensionDir);
2918
+ const resolved = resolveProjectProfileDocument(platformRoot, profile).profile as ProjectProfile;
2919
+ const capabilityLock = resolveCapabilityProfileDocument(platformRoot, resolved, {
2928
2920
  profileFile: "piagent-profile.json",
2921
+ storedProfile: profile,
2929
2922
  packageSource: projectPackageSource(cwd),
2930
- extraRoots: capabilitySourceRoots(cwd, profile)
2923
+ extraRoots: capabilitySourceRoots(cwd, resolved)
2931
2924
  });
2932
2925
  fs.mkdirSync(path.dirname(target), { recursive: true });
2933
2926
  writeProfileLockAtomic(target, profile, path.join(cwd, ".pi", "piagent-profile.lock.json"), capabilityLock);
@@ -2977,7 +2970,7 @@ function writeTechStackSelection(
2977
2970
  };
2978
2971
  writeProfileDocumentWithLock(extensionDir, cwd, profile);
2979
2972
  ensureProjectContextPlaceholder(cwd);
2980
- return { profile, manifest };
2973
+ return { profile: resolveProjectProfileDocument(findPlatformRoot(extensionDir), profile).profile as ProjectProfile, manifest };
2981
2974
  }
2982
2975
 
2983
2976
  function normalizeTechSelections(cwd: string, profileName: string, selections: Record<string, unknown> = {}, fillRecommended = true): {
@@ -3101,20 +3094,24 @@ function buildProfileFromAdapter(extensionDir: string, cwd: string, profileName:
3101
3094
  const profile = readJsonFile<ProjectProfile>(source);
3102
3095
  if (!profile) throw new Error(`Profile unreadable: ${profileName}`);
3103
3096
  const projectName = path.basename(cwd);
3104
- return {
3105
- ...profile,
3106
- projectId: projectId ? slugify(projectId) : slugify(projectName),
3107
- displayName: displayName?.trim() || titleize(projectName)
3108
- };
3097
+ // The project records which adapter it follows, not a copy of that adapter's
3098
+ // rules. A copy stops receiving corrections the moment it is written.
3099
+ return buildExtendingProfile(
3100
+ profileName,
3101
+ projectId ? slugify(projectId) : slugify(projectName),
3102
+ displayName?.trim() || titleize(projectName)
3103
+ ) as ProjectProfile;
3109
3104
  }
3110
3105
 
3111
3106
  function writeProfileFromAdapter(extensionDir: string, cwd: string, profileName: string, overwrite = false, projectId?: string, displayName?: string): ProjectProfile {
3112
3107
  const target = projectProfilePath(cwd);
3113
3108
  if (fs.existsSync(target) && !overwrite) throw new Error(".pi/piagent-profile.json already exists. Pass overwrite=true to replace it.");
3114
- const personalized = buildProfileFromAdapter(extensionDir, cwd, profileName, projectId, displayName);
3115
- writeProfileDocumentWithLock(extensionDir, cwd, personalized);
3109
+ const stored = buildProfileFromAdapter(extensionDir, cwd, profileName, projectId, displayName);
3110
+ writeProfileDocumentWithLock(extensionDir, cwd, stored);
3116
3111
  ensureProjectContextPlaceholder(cwd);
3117
- return personalized;
3112
+ // Store the reference, hand back the document it resolves to: callers report
3113
+ // and trace what is now in force, not the four lines that point at it.
3114
+ return resolveProjectProfileDocument(findPlatformRoot(extensionDir), stored).profile as ProjectProfile;
3118
3115
  }
3119
3116
 
3120
3117
  function readTask(cwd: string, taskId: string): TaskContract | undefined {
@@ -3727,6 +3724,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
3727
3724
  const permissionHint = ` permission=${permissionProfile.mode}`;
3728
3725
  ctx.ui.notify(`Piagent Pi guard loaded: ${name}${profileHint}${permissionHint}${contextHint}`, preflight.recommendation === "fresh-session" ? "warning" : "info");
3729
3726
  if (!capabilityState.ok) ctx.ui.notify(capabilityState.reason ?? "Capability validation failed.", "warning");
3727
+ if (capabilityState.repinned) {
3728
+ ctx.ui.notify(`Capability lock re-pinned: ${capabilityState.repinned}. The capabilities this project grants are unchanged.`, "info");
3729
+ }
3730
3730
  const legacyWarning = legacyProjectStateWarning(ctx.cwd);
3731
3731
  if (legacyWarning) ctx.ui.notify(legacyWarning, "warning");
3732
3732
  if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
@@ -4743,10 +4743,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
4743
4743
  entry.context7.digest = snapshot.digest;
4744
4744
  manifest.updatedAt = nowIso();
4745
4745
  fs.writeFileSync(techStackPath(ctx.cwd), `${JSON.stringify(manifest, null, 2)}\n`);
4746
- const profile = loadProfileFromContext(ctx);
4747
- if (profile.techStack) {
4748
- profile.techStack.updatedAt = manifest.updatedAt;
4749
- writeProfileDocumentWithLock(extensionDir, ctx.cwd, profile);
4746
+ // Write back the document the project stores, not the resolved one: saving
4747
+ // the resolved copy would inline the adapter and stop it following.
4748
+ const stored = readJsonFile<ProjectProfile>(projectProfilePath(ctx.cwd));
4749
+ if (stored?.techStack) {
4750
+ stored.techStack.updatedAt = manifest.updatedAt;
4751
+ writeProfileDocumentWithLock(extensionDir, ctx.cwd, stored);
4750
4752
  }
4751
4753
  appendTrace(ctx.cwd, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
4752
4754
  appendSessionTrace(pi, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
@@ -0,0 +1,285 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // The shape of a repository, in the only terms the profiles care about: is there
5
+ // a frontend, is there a backend, and what else is sitting at the root. Both the
6
+ // runtime recommendation (`piagent_profile_options`) and the shell initialiser
7
+ // (`scripts/init-project.sh`) read it from here. They used to carry separate
8
+ // copies of the same rules, which is how `apps/backend` ended up being invisible
9
+ // to one of them for several releases.
10
+
11
+ // Directories that name a side of the stack outright. These are matched at the
12
+ // repository root and one level under each workspace root, never at arbitrary
13
+ // depth: `src/api` inside a frontend package is an HTTP client, not a backend.
14
+ const FRONTEND_DIR_NAMES = ["frontend", "web", "client", "ui"];
15
+ const BACKEND_DIR_NAMES = ["backend", "server", "api"];
16
+ const WORKSPACE_ROOTS = ["apps", "packages", "services"];
17
+
18
+ const FRONTEND_DEPENDENCIES = /"(next|react|vite|vue|svelte|astro|@angular\/core|remix)"/i;
19
+ const BACKEND_DEPENDENCIES = /"(@nestjs|express|fastify|hono|koa|apollo-server|graphql-yoga|prisma|typeorm|sequelize|drizzle-orm)"/i;
20
+ const PYTHON_BACKEND_FRAMEWORKS = /(fastapi|flask|django|litestar|starlite)/i;
21
+
22
+ const FRONTEND_MARKER_FILES = [
23
+ "next.config.js",
24
+ "next.config.mjs",
25
+ "next.config.ts",
26
+ "vite.config.js",
27
+ "vite.config.ts",
28
+ "src/app",
29
+ "pages",
30
+ "public"
31
+ ];
32
+ const NODE_BACKEND_MARKER_FILES = ["nest-cli.json", "prisma", "src/server", "src/api"];
33
+ // Markers that identify a backend on their own, with no manifest to lean on.
34
+ const NATIVE_BACKEND_MARKER_FILES = [
35
+ "pom.xml",
36
+ "build.gradle",
37
+ "build.gradle.kts",
38
+ "src/main/java",
39
+ "src/main/kotlin"
40
+ ];
41
+
42
+ // A workspace file can declare any number of packages. Reading a bounded number
43
+ // of them keeps a repository with hundreds of packages from turning profile
44
+ // recommendation into a directory walk.
45
+ const MAX_WORKSPACE_PACKAGES = 64;
46
+
47
+ function exists(dir, relative) {
48
+ try {
49
+ return fs.existsSync(path.join(dir, relative));
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // The stack-naming markers are directories. A file called `api` says nothing
56
+ // about a repository having a backend in it.
57
+ function isDirectory(dir, relative) {
58
+ try {
59
+ return fs.statSync(path.join(dir, relative)).isDirectory();
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function readFile(dir, relative) {
66
+ try {
67
+ return fs.readFileSync(path.join(dir, relative), "utf8");
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ function readJson(dir, relative) {
74
+ const text = readFile(dir, relative);
75
+ if (text === undefined) return undefined;
76
+ try {
77
+ return JSON.parse(text);
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ function stripQuotes(value) {
84
+ return value.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim();
85
+ }
86
+
87
+ // npm and yarn put the globs in package.json, pnpm puts them in its own file.
88
+ // Both are read: a repository is free to carry either, and some carry both.
89
+ function readWorkspacePatterns(cwd) {
90
+ const patterns = [];
91
+
92
+ const declared = readJson(cwd, "package.json")?.workspaces;
93
+ if (Array.isArray(declared)) {
94
+ patterns.push(...declared);
95
+ } else if (Array.isArray(declared?.packages)) {
96
+ patterns.push(...declared.packages);
97
+ }
98
+
99
+ patterns.push(...readPnpmWorkspacePatterns(cwd));
100
+ return patterns.filter((pattern) => typeof pattern === "string");
101
+ }
102
+
103
+ // A three-line YAML reader rather than a YAML dependency: this package ships
104
+ // with no runtime dependencies, and the shape being read is a single list of
105
+ // strings under one key.
106
+ function readPnpmWorkspacePatterns(cwd) {
107
+ const text = readFile(cwd, "pnpm-workspace.yaml") ?? readFile(cwd, "pnpm-workspace.yml");
108
+ if (text === undefined) return [];
109
+
110
+ const patterns = [];
111
+ let insidePackages = false;
112
+ for (const rawLine of text.split(/\r?\n/)) {
113
+ const line = rawLine.replace(/#.*$/, "");
114
+ if (/^packages\s*:/.test(line)) {
115
+ insidePackages = true;
116
+ continue;
117
+ }
118
+ if (!insidePackages) continue;
119
+
120
+ const item = line.match(/^\s+-\s*(.+?)\s*$/);
121
+ if (item) {
122
+ patterns.push(stripQuotes(item[1]));
123
+ continue;
124
+ }
125
+ // A blank line stays inside the list; anything else starts the next key.
126
+ if (line.trim().length > 0) break;
127
+ }
128
+ return patterns;
129
+ }
130
+
131
+ function expandWorkspacePattern(cwd, pattern) {
132
+ const normalized = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
133
+ // `!` excludes rather than includes, and neither an absolute path nor one that
134
+ // climbs out of the repository is a workspace of this repository.
135
+ if (!normalized || normalized.startsWith("!") || normalized.startsWith("/")) return [];
136
+
137
+ const segments = normalized.split("/").filter((segment) => segment && segment !== ".");
138
+ if (segments.length === 0 || segments.includes("..")) return [];
139
+
140
+ const wildcardAt = segments.findIndex((segment) => segment.includes("*"));
141
+ if (wildcardAt === -1) return [segments.join("/")];
142
+ // Only a trailing wildcard is expanded. `packages/*` and `apps/**` are what
143
+ // workspace files actually declare; anything deeper is left to the directory
144
+ // and dependency markers instead of growing a glob engine in here.
145
+ if (wildcardAt !== segments.length - 1) return [];
146
+
147
+ const base = segments.slice(0, wildcardAt).join("/");
148
+ let entries;
149
+ try {
150
+ entries = fs.readdirSync(base ? path.join(cwd, base) : cwd, { withFileTypes: true });
151
+ } catch {
152
+ return [];
153
+ }
154
+ return entries
155
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
156
+ .map((entry) => (base ? `${base}/${entry.name}` : entry.name));
157
+ }
158
+
159
+ export function readWorkspacePackageDirs(cwd) {
160
+ const found = [];
161
+ const seen = new Set();
162
+ for (const pattern of readWorkspacePatterns(cwd)) {
163
+ for (const relative of expandWorkspacePattern(cwd, pattern)) {
164
+ if (seen.has(relative)) continue;
165
+ if (!exists(cwd, relative)) continue;
166
+ seen.add(relative);
167
+ found.push(relative);
168
+ if (found.length >= MAX_WORKSPACE_PACKAGES) return found;
169
+ }
170
+ }
171
+ return found;
172
+ }
173
+
174
+ // What one directory says about itself, from its manifest and its own layout.
175
+ // Applied to the repository root and to every declared workspace package, so a
176
+ // pnpm monorepo whose packages are named `web` and `api` is read the same way a
177
+ // single-package repository is.
178
+ function directorySignals(dir) {
179
+ const manifest = readFile(dir, "package.json");
180
+ let frontend = false;
181
+ let backend = false;
182
+
183
+ if (manifest !== undefined) {
184
+ frontend = FRONTEND_DEPENDENCIES.test(manifest);
185
+ backend = BACKEND_DEPENDENCIES.test(manifest);
186
+ // Node layout markers only mean something next to a manifest. `public/` and
187
+ // `pages/` are ordinary directory names in repositories that have nothing to
188
+ // do with a web framework.
189
+ if (!frontend) frontend = FRONTEND_MARKER_FILES.some((marker) => exists(dir, marker));
190
+ if (!backend) backend = NODE_BACKEND_MARKER_FILES.some((marker) => exists(dir, marker));
191
+ }
192
+
193
+ if (!backend) backend = NATIVE_BACKEND_MARKER_FILES.some((marker) => exists(dir, marker));
194
+
195
+ const pyproject = readFile(dir, "pyproject.toml");
196
+ if (pyproject !== undefined && PYTHON_BACKEND_FRAMEWORKS.test(pyproject)) backend = true;
197
+
198
+ return { frontend, backend };
199
+ }
200
+
201
+ function shapeFromDirName(name) {
202
+ const normalized = name.toLowerCase();
203
+ if (FRONTEND_DIR_NAMES.includes(normalized)) return "frontend";
204
+ if (BACKEND_DIR_NAMES.includes(normalized)) return "backend";
205
+ return undefined;
206
+ }
207
+
208
+ export function detectProjectShape(cwd) {
209
+ const hasPackage = exists(cwd, "package.json");
210
+ const shape = {
211
+ hasPackage,
212
+ frontend: false,
213
+ backend: false,
214
+ data: false,
215
+ mobile: false,
216
+ infra: false,
217
+ docs: false,
218
+ workspacePackages: []
219
+ };
220
+
221
+ if (exists(cwd, "pubspec.yaml") || (exists(cwd, "android") && exists(cwd, "ios"))) shape.mobile = true;
222
+ if (exists(cwd, "dbt_project.yml") || exists(cwd, "dvc.yaml") || exists(cwd, "notebooks") || exists(cwd, "data")) {
223
+ shape.data = true;
224
+ }
225
+ if (exists(cwd, "Dockerfile") || exists(cwd, "docker-compose.yml") || exists(cwd, "compose.yml")
226
+ || exists(cwd, "compose.yaml") || exists(cwd, "terraform") || exists(cwd, "infra")
227
+ || exists(cwd, "k8s") || exists(cwd, "helm")) {
228
+ shape.infra = true;
229
+ }
230
+ if (exists(cwd, "docs") || exists(cwd, "mkdocs.yml") || exists(cwd, "mint.json") || exists(cwd, "docusaurus.config.js")) {
231
+ shape.docs = true;
232
+ }
233
+
234
+ // Directory names are read whether or not the root carries a package.json.
235
+ // A Django and React repository has `frontend/` and `backend/` and no root
236
+ // manifest at all; gating this on package.json used to hide both sides of it.
237
+ for (const name of [...FRONTEND_DIR_NAMES, ...BACKEND_DIR_NAMES]) {
238
+ const side = shapeFromDirName(name);
239
+ if (isDirectory(cwd, name)) shape[side] = true;
240
+ for (const root of WORKSPACE_ROOTS) {
241
+ if (isDirectory(cwd, `${root}/${name}`)) shape[side] = true;
242
+ }
243
+ }
244
+
245
+ const rootSignals = directorySignals(cwd);
246
+ if (rootSignals.frontend) shape.frontend = true;
247
+ if (rootSignals.backend) shape.backend = true;
248
+
249
+ shape.workspacePackages = readWorkspacePackageDirs(cwd);
250
+ for (const relative of shape.workspacePackages) {
251
+ const side = shapeFromDirName(path.posix.basename(relative));
252
+ if (side) shape[side] = true;
253
+
254
+ const signals = directorySignals(path.join(cwd, relative));
255
+ if (signals.frontend) shape.frontend = true;
256
+ if (signals.backend) shape.backend = true;
257
+ }
258
+
259
+ return shape;
260
+ }
261
+
262
+ export function detectProfileName(cwd, intent) {
263
+ const normalizedIntent = typeof intent === "string" ? intent.trim().toLowerCase() : "";
264
+ if (normalizedIntent === "be-readonly-fe") {
265
+ return { name: "be-readonly-fe", reason: "User intent says backend should be read-only while frontend is the write target." };
266
+ }
267
+ if (normalizedIntent === "frontend-only") return { name: "web-frontend", reason: "User intent says frontend-only." };
268
+ if (normalizedIntent === "backend-only") return { name: "backend-api", reason: "User intent says backend-only." };
269
+ if (normalizedIntent === "docs") return { name: "docs", reason: "User intent says docs/docs-only." };
270
+
271
+ const shape = detectProjectShape(cwd);
272
+
273
+ if (shape.mobile) return { name: "mobile", reason: "Mobile markers found." };
274
+ if (shape.frontend && shape.backend) {
275
+ return { name: "fullstack", reason: "Frontend and backend markers both found. Pick be-readonly-fe instead if backend must be read-only." };
276
+ }
277
+ if (shape.frontend) return { name: "web-frontend", reason: "Frontend framework markers found." };
278
+ if (shape.backend) return { name: "backend-api", reason: "Backend/API markers found." };
279
+ if (shape.data) return { name: "data", reason: "Data/ETL markers found." };
280
+ if (exists(cwd, "pyproject.toml")) return { name: "python", reason: "Python pyproject.toml found." };
281
+ if (shape.hasPackage && exists(cwd, "tsconfig.json")) return { name: "node-typescript", reason: "Node TypeScript markers found." };
282
+ if (shape.infra) return { name: "devops", reason: "Infrastructure markers found." };
283
+ if (shape.docs) return { name: "docs", reason: "Documentation markers found." };
284
+ return { name: "generic", reason: "No stronger project markers found." };
285
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",