@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.
- package/CHANGELOG.md +34 -0
- package/README.md +1 -1
- package/adapters/be-readonly-fe/profile.json +12 -4
- package/docs/capability-packs.md +21 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/project-adapters.md +44 -0
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +1 -1
- package/docs/team-onboarding.md +3 -3
- package/docs-site/index.html +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/capabilities/capability-core.js +109 -8
- package/packages/piagent-core/capabilities/project-profile.js +83 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +97 -95
- package/packages/piagent-core/extensions/project-shape.js +285 -0
- package/packages/piagent-core/package.json +1 -1
- package/schemas/project-profile.schema.json +25 -7
- package/scripts/capability-catalog.mjs +28 -7
- package/scripts/init-project.sh +19 -89
- package/scripts/install-global.sh +3 -3
- package/scripts/profile-doctor.sh +13 -3
- package/scripts/runtime-policy-smoke.sh +12 -3
- package/scripts/team-doctor.sh +12 -3
|
@@ -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)
|
|
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"))
|
|
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
|
-
|
|
542
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
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
|
|
3115
|
-
writeProfileDocumentWithLock(extensionDir, cwd,
|
|
3109
|
+
const stored = buildProfileFromAdapter(extensionDir, cwd, profileName, projectId, displayName);
|
|
3110
|
+
writeProfileDocumentWithLock(extensionDir, cwd, stored);
|
|
3116
3111
|
ensureProjectContextPlaceholder(cwd);
|
|
3117
|
-
|
|
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
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
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
|
+
}
|