@piagent/platform 1.1.6 → 1.1.8

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
+ }
@@ -40,9 +40,12 @@ import {
40
40
  redactSensitiveText
41
41
  } from "./redaction-core.js";
42
42
  import { detectProfileName } from "./project-shape.js";
43
+ import { evaluateUpdateCheck, readUpdateCache, startUpdateProbe } from "./update-check.js";
44
+ import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
43
45
  import {
44
46
  resolveCapabilityProfileDocument,
45
47
  verifyCapabilityLock,
48
+ writeJsonAtomic,
46
49
  writeProfileLockAtomic
47
50
  } from "../capabilities/capability-core.js";
48
51
  import { resolveCapabilitySourceRoots } from "../capabilities/capability-sources.js";
@@ -417,6 +420,28 @@ const DEFAULT_POLICY: BasePolicy = {
417
420
  orchestrationPolicy: DEFAULT_ORCHESTRATION_POLICY
418
421
  };
419
422
 
423
+ // Which platform supplies the adapters is fixed by where this file is installed,
424
+ // so it is resolved once here rather than threaded through every profile load.
425
+ const PLATFORM_ROOT = findPlatformRoot(path.dirname(fileURLToPath(import.meta.url)));
426
+ const UPDATE_CHECK_MODULE = fileURLToPath(new URL("./update-check.js", import.meta.url));
427
+
428
+ // The installed version, read from the package this file ships in. A maintainer
429
+ // working in the repository is not running a release and has nothing to update
430
+ // to, so the checkout is recognised by the one thing a published copy never
431
+ // carries and told nothing.
432
+ function installedPlatformVersion(): string | undefined {
433
+ if (fs.existsSync(path.join(PLATFORM_ROOT, ".git"))) return undefined;
434
+ return readJsonFile<{ version?: string }>(path.join(PLATFORM_ROOT, "package.json"))?.version;
435
+ }
436
+
437
+ function updateAvailabilityNotice(): string | undefined {
438
+ const installed = installedPlatformVersion();
439
+ if (!installed) return undefined;
440
+ const decision = evaluateUpdateCheck({ installed, cache: readUpdateCache(), now: Date.now() });
441
+ if (decision.probe) startUpdateProbe(UPDATE_CHECK_MODULE);
442
+ return decision.notice;
443
+ }
444
+
420
445
  function loadPolicy(extensionDir: string): BasePolicy {
421
446
  const root = findPackageRoot(extensionDir);
422
447
  return readJsonFile<BasePolicy>(path.join(root, "policies", "base-policy.json")) ?? DEFAULT_POLICY;
@@ -433,17 +458,38 @@ function fallbackProfile(cwd: string, mode = "unprofiled"): ProjectProfile {
433
458
  };
434
459
  }
435
460
 
461
+ // A profile that names an adapter this platform does not have cannot be
462
+ // enforced, and the unprofiled shape carries no readOnlyPaths — falling back to
463
+ // it would quietly hand write access to whatever the profile was protecting. The
464
+ // session stays readable and says why instead.
465
+ function unresolvedProfile(cwd: string, reason: string): ProjectProfile {
466
+ return {
467
+ ...fallbackProfile(cwd, "unresolved-profile-base"),
468
+ permissionProfile: "read-only",
469
+ unresolvedReason: reason
470
+ } as ProjectProfile;
471
+ }
472
+
473
+ function resolveStoredProfile(stored: ProjectProfile | undefined, cwd: string, missingMode: string): ProjectProfile {
474
+ if (!stored) return fallbackProfile(cwd, missingMode);
475
+ try {
476
+ return resolveProjectProfileDocument(PLATFORM_ROOT, stored).profile as ProjectProfile;
477
+ } catch (error) {
478
+ return unresolvedProfile(cwd, error instanceof Error ? error.message : String(error));
479
+ }
480
+ }
481
+
436
482
  function loadProfile(cwd: string, projectTrusted = false): ProjectProfile {
437
483
  const explicit = process.env.PIAGENT_PROFILE;
438
484
  if (explicit && explicit.trim().length > 0) {
439
- return readJsonFile<ProjectProfile>(explicit) ?? fallbackProfile(cwd, "explicit-profile-unreadable");
485
+ return resolveStoredProfile(readJsonFile<ProjectProfile>(explicit), cwd, "explicit-profile-unreadable");
440
486
  }
441
487
 
442
488
  if (!projectTrusted) {
443
489
  return fallbackProfile(cwd, "unprofiled-global-package");
444
490
  }
445
491
 
446
- return readJsonFile<ProjectProfile>(path.join(cwd, ".pi", "piagent-profile.json")) ?? fallbackProfile(cwd, projectTrusted ? "unprofiled" : "unprofiled-global-package");
492
+ return resolveStoredProfile(readJsonFile<ProjectProfile>(path.join(cwd, ".pi", "piagent-profile.json")), cwd, "unprofiled");
447
493
  }
448
494
 
449
495
  function loadProfileFromContext(ctx: ExtensionContext): ProjectProfile {
@@ -530,6 +576,7 @@ function capabilitySourceRoots(
530
576
  type ProjectCapabilityState = {
531
577
  ok: boolean;
532
578
  reason?: string;
579
+ repinned?: string;
533
580
  filesystemRead?: string[];
534
581
  filesystemWrite?: string[];
535
582
  };
@@ -539,8 +586,18 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
539
586
  if (!projectTrusted) return { ok: true };
540
587
  const profilePath = projectProfilePath(cwd);
541
588
  if (!fs.existsSync(profilePath)) return { ok: true };
542
- const profile = readJsonFile<ProjectProfile>(profilePath);
543
- if (!profile || !Array.isArray(profile.capabilityPacks)) return { ok: true };
589
+ // Which packs a project selects can come from the adapter it extends, so the
590
+ // question has to be asked of the resolved document. Asking the stored one
591
+ // would skip the lock entirely for every project that references an adapter.
592
+ const stored = readJsonFile<ProjectProfile>(profilePath);
593
+ if (!stored) return { ok: true };
594
+ let profile: ProjectProfile;
595
+ try {
596
+ profile = resolveProjectProfileDocument(PLATFORM_ROOT, stored).profile as ProjectProfile;
597
+ } catch (error) {
598
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
599
+ }
600
+ if (!Array.isArray(profile.capabilityPacks)) return { ok: true };
544
601
  const lockPath = path.join(cwd, ".pi", "piagent-profile.lock.json");
545
602
  if (!fs.existsSync(lockPath)) return { ok: false, reason: "Capability lock is missing. Reapply the project profile." };
546
603
  const lock = readJsonFile<Record<string, unknown>>(lockPath);
@@ -550,13 +607,30 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
550
607
  packageSource: projectPackageSource(cwd),
551
608
  extraRoots: capabilitySourceRoots(cwd, profile)
552
609
  });
553
- return verification.ok
554
- ? {
555
- ok: true,
556
- filesystemRead: verification.expected.permissions.filesystemRead,
557
- filesystemWrite: verification.expected.permissions.filesystemWrite
558
- }
559
- : { ok: false, reason: "Capability lock does not match the active profile, package source, or installed platform." };
610
+ if (verification.status === "blocked") {
611
+ return {
612
+ ok: false,
613
+ reason: `Capability lock does not match what this project agreed to: ${verification.reasons.join("; ")}. Reapply the project profile.`
614
+ };
615
+ }
616
+ const granted = {
617
+ filesystemRead: verification.expected.permissions.filesystemRead,
618
+ filesystemWrite: verification.expected.permissions.filesystemWrite
619
+ };
620
+ if (verification.status !== "repin") return { ok: true, ...granted };
621
+
622
+ // The grant is unchanged and only the platform build moved. Record the new
623
+ // build instead of stopping the session, which is what turned every release
624
+ // into a chore in every project.
625
+ try {
626
+ writeJsonAtomic(lockPath, verification.expected);
627
+ } catch (error) {
628
+ return {
629
+ ok: false,
630
+ reason: `Capability lock is behind the installed platform and could not be rewritten: ${error instanceof Error ? error.message : String(error)}`
631
+ };
632
+ }
633
+ return { ok: true, repinned: verification.reasons.join("; "), ...granted };
560
634
  } catch (error) {
561
635
  return { ok: false, reason: `Capability validation failed: ${error instanceof Error ? error.message : String(error)}` };
562
636
  }
@@ -2854,12 +2928,18 @@ function buildTechStackManifest(profileName: string, selectedOptions: TechOption
2854
2928
  };
2855
2929
  }
2856
2930
 
2931
+ // `profile` is the document the project stores. When it names an adapter, the
2932
+ // lock is resolved against that adapter but still records the stored document,
2933
+ // so a later platform correction is not a lock the project has to re-agree to.
2857
2934
  function writeProfileDocumentWithLock(extensionDir: string, cwd: string, profile: ProjectProfile): ProjectProfile {
2858
2935
  const target = projectProfilePath(cwd);
2859
- const capabilityLock = resolveCapabilityProfileDocument(findPlatformRoot(extensionDir), profile, {
2936
+ const platformRoot = findPlatformRoot(extensionDir);
2937
+ const resolved = resolveProjectProfileDocument(platformRoot, profile).profile as ProjectProfile;
2938
+ const capabilityLock = resolveCapabilityProfileDocument(platformRoot, resolved, {
2860
2939
  profileFile: "piagent-profile.json",
2940
+ storedProfile: profile,
2861
2941
  packageSource: projectPackageSource(cwd),
2862
- extraRoots: capabilitySourceRoots(cwd, profile)
2942
+ extraRoots: capabilitySourceRoots(cwd, resolved)
2863
2943
  });
2864
2944
  fs.mkdirSync(path.dirname(target), { recursive: true });
2865
2945
  writeProfileLockAtomic(target, profile, path.join(cwd, ".pi", "piagent-profile.lock.json"), capabilityLock);
@@ -2909,7 +2989,7 @@ function writeTechStackSelection(
2909
2989
  };
2910
2990
  writeProfileDocumentWithLock(extensionDir, cwd, profile);
2911
2991
  ensureProjectContextPlaceholder(cwd);
2912
- return { profile, manifest };
2992
+ return { profile: resolveProjectProfileDocument(findPlatformRoot(extensionDir), profile).profile as ProjectProfile, manifest };
2913
2993
  }
2914
2994
 
2915
2995
  function normalizeTechSelections(cwd: string, profileName: string, selections: Record<string, unknown> = {}, fillRecommended = true): {
@@ -3033,20 +3113,24 @@ function buildProfileFromAdapter(extensionDir: string, cwd: string, profileName:
3033
3113
  const profile = readJsonFile<ProjectProfile>(source);
3034
3114
  if (!profile) throw new Error(`Profile unreadable: ${profileName}`);
3035
3115
  const projectName = path.basename(cwd);
3036
- return {
3037
- ...profile,
3038
- projectId: projectId ? slugify(projectId) : slugify(projectName),
3039
- displayName: displayName?.trim() || titleize(projectName)
3040
- };
3116
+ // The project records which adapter it follows, not a copy of that adapter's
3117
+ // rules. A copy stops receiving corrections the moment it is written.
3118
+ return buildExtendingProfile(
3119
+ profileName,
3120
+ projectId ? slugify(projectId) : slugify(projectName),
3121
+ displayName?.trim() || titleize(projectName)
3122
+ ) as ProjectProfile;
3041
3123
  }
3042
3124
 
3043
3125
  function writeProfileFromAdapter(extensionDir: string, cwd: string, profileName: string, overwrite = false, projectId?: string, displayName?: string): ProjectProfile {
3044
3126
  const target = projectProfilePath(cwd);
3045
3127
  if (fs.existsSync(target) && !overwrite) throw new Error(".pi/piagent-profile.json already exists. Pass overwrite=true to replace it.");
3046
- const personalized = buildProfileFromAdapter(extensionDir, cwd, profileName, projectId, displayName);
3047
- writeProfileDocumentWithLock(extensionDir, cwd, personalized);
3128
+ const stored = buildProfileFromAdapter(extensionDir, cwd, profileName, projectId, displayName);
3129
+ writeProfileDocumentWithLock(extensionDir, cwd, stored);
3048
3130
  ensureProjectContextPlaceholder(cwd);
3049
- return personalized;
3131
+ // Store the reference, hand back the document it resolves to: callers report
3132
+ // and trace what is now in force, not the four lines that point at it.
3133
+ return resolveProjectProfileDocument(findPlatformRoot(extensionDir), stored).profile as ProjectProfile;
3050
3134
  }
3051
3135
 
3052
3136
  function readTask(cwd: string, taskId: string): TaskContract | undefined {
@@ -3659,12 +3743,19 @@ export default function piagentGuard(pi: ExtensionAPI) {
3659
3743
  const permissionHint = ` permission=${permissionProfile.mode}`;
3660
3744
  ctx.ui.notify(`Piagent Pi guard loaded: ${name}${profileHint}${permissionHint}${contextHint}`, preflight.recommendation === "fresh-session" ? "warning" : "info");
3661
3745
  if (!capabilityState.ok) ctx.ui.notify(capabilityState.reason ?? "Capability validation failed.", "warning");
3746
+ if (capabilityState.repinned) {
3747
+ ctx.ui.notify(`Capability lock re-pinned: ${capabilityState.repinned}. The capabilities this project grants are unchanged.`, "info");
3748
+ }
3662
3749
  const legacyWarning = legacyProjectStateWarning(ctx.cwd);
3663
3750
  if (legacyWarning) ctx.ui.notify(legacyWarning, "warning");
3664
3751
  if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
3665
3752
  if (permissionProfile.mode === "trusted-full-access") {
3666
3753
  ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
3667
3754
  }
3755
+ // Last, so a release announcement never pushes a security warning out of
3756
+ // the first thing the operator reads.
3757
+ const updateNotice = updateAvailabilityNotice();
3758
+ if (updateNotice) ctx.ui.notify(updateNotice, "info");
3668
3759
  });
3669
3760
 
3670
3761
  pi.on("input", async (event, ctx) => {
@@ -4675,10 +4766,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
4675
4766
  entry.context7.digest = snapshot.digest;
4676
4767
  manifest.updatedAt = nowIso();
4677
4768
  fs.writeFileSync(techStackPath(ctx.cwd), `${JSON.stringify(manifest, null, 2)}\n`);
4678
- const profile = loadProfileFromContext(ctx);
4679
- if (profile.techStack) {
4680
- profile.techStack.updatedAt = manifest.updatedAt;
4681
- writeProfileDocumentWithLock(extensionDir, ctx.cwd, profile);
4769
+ // Write back the document the project stores, not the resolved one: saving
4770
+ // the resolved copy would inline the adapter and stop it following.
4771
+ const stored = readJsonFile<ProjectProfile>(projectProfilePath(ctx.cwd));
4772
+ if (stored?.techStack) {
4773
+ stored.techStack.updatedAt = manifest.updatedAt;
4774
+ writeProfileDocumentWithLock(extensionDir, ctx.cwd, stored);
4682
4775
  }
4683
4776
  appendTrace(ctx.cwd, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
4684
4777
  appendSessionTrace(pi, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
@@ -0,0 +1,170 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // Telling someone a new release exists is only useful if it costs them nothing.
8
+ // A session start that waits on the registry pays a network round trip every
9
+ // time Pi opens, for information that changes a few times a month, so the check
10
+ // never runs in the session's path: the session reads a cache file and, when
11
+ // that cache is old, leaves a detached process behind to refresh it. The notice
12
+ // lands on this session if the cache already knew, and on the next one if it did
13
+ // not.
14
+ //
15
+ // Everything read here comes from outside the process — a registry response and
16
+ // a file any local process can write — so the only value that ever reaches the
17
+ // terminal is a version string that matched RELEASE_VERSION. A registry that
18
+ // answers with a sentence, or a cache someone edited to carry one, produces no
19
+ // notice rather than a line of attacker-chosen text in the operator's terminal.
20
+
21
+ const HELPER_PACKAGE = "@piagent/platform";
22
+ const CACHE_FILE = "piagent-update-check.json";
23
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
24
+ const PROBE_TIMEOUT_MS = 10_000;
25
+ const MAX_CACHE_BYTES = 4096;
26
+ const RELEASE_VERSION = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
27
+
28
+ /** @param {string} [home] @returns {string|undefined} */
29
+ export function updateCacheFile(home) {
30
+ const base = home ?? os.homedir();
31
+ if (typeof base !== "string" || base.length === 0) return undefined;
32
+ return path.join(base, ".pi", CACHE_FILE);
33
+ }
34
+
35
+ /**
36
+ * A release version and nothing else. Prereleases and build metadata are
37
+ * rejected rather than parsed: this decides what to tell someone to install, and
38
+ * nudging an operator onto a prerelease is not what the notice is for.
39
+ * @param {unknown} value @returns {string|undefined}
40
+ */
41
+ export function releaseVersion(value) {
42
+ if (typeof value !== "string") return undefined;
43
+ const trimmed = value.trim();
44
+ return RELEASE_VERSION.test(trimmed) ? trimmed : undefined;
45
+ }
46
+
47
+ /**
48
+ * Compare two release versions numerically. `1.1.10` is after `1.1.9`, which
49
+ * string ordering gets backwards.
50
+ * @param {string} left @param {string} right @returns {number}
51
+ */
52
+ export function compareReleaseVersions(left, right) {
53
+ const leftParts = left.split(".").map(Number);
54
+ const rightParts = right.split(".").map(Number);
55
+ for (let index = 0; index < 3; index += 1) {
56
+ if (leftParts[index] !== rightParts[index]) return leftParts[index] < rightParts[index] ? -1 : 1;
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ /** @param {string} [home] @returns {{latest?: string, checkedAt?: number}} */
62
+ export function readUpdateCache(home) {
63
+ const file = updateCacheFile(home);
64
+ if (!file) return {};
65
+ try {
66
+ // A cache larger than this is not something this code wrote.
67
+ if (fs.statSync(file).size > MAX_CACHE_BYTES) return {};
68
+ const document = JSON.parse(fs.readFileSync(file, "utf8"));
69
+ const checkedAt = Date.parse(document?.checkedAt);
70
+ return {
71
+ latest: releaseVersion(document?.latest),
72
+ checkedAt: Number.isFinite(checkedAt) ? checkedAt : undefined
73
+ };
74
+ } catch {
75
+ return {};
76
+ }
77
+ }
78
+
79
+ /** @param {string|undefined} home @param {string} latest @param {number} now @returns {boolean} */
80
+ export function writeUpdateCache(home, latest, now) {
81
+ const file = updateCacheFile(home);
82
+ const version = releaseVersion(latest);
83
+ if (!file || !version) return false;
84
+ const document = {
85
+ schemaVersion: 1,
86
+ package: HELPER_PACKAGE,
87
+ latest: version,
88
+ checkedAt: new Date(now).toISOString()
89
+ };
90
+ try {
91
+ fs.mkdirSync(path.dirname(file), { recursive: true });
92
+ const temporary = `${file}.${process.pid}.tmp`;
93
+ fs.writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`);
94
+ fs.renameSync(temporary, file);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * What the session should do about updates, decided from state alone so it can
103
+ * be tested without a registry or a clock.
104
+ * @param {{installed?: string, cache?: {latest?: string, checkedAt?: number}, now: number, env?: Record<string, string|undefined>}} options
105
+ * @returns {{notice?: string, probe: boolean}}
106
+ */
107
+ export function evaluateUpdateCheck(options) {
108
+ const env = options.env ?? process.env;
109
+ if (env.PIAGENT_NO_UPDATE_CHECK?.trim()) return { probe: false };
110
+
111
+ const installed = releaseVersion(options.installed);
112
+ const cache = options.cache ?? {};
113
+ const latest = releaseVersion(cache.latest);
114
+ const probe = !(typeof cache.checkedAt === "number" && options.now - cache.checkedAt < CACHE_TTL_MS);
115
+
116
+ if (!installed || !latest || compareReleaseVersions(latest, installed) <= 0) return { probe };
117
+ return {
118
+ probe,
119
+ notice: `Piagent update available: ${installed} -> ${latest}. Run \`piagent-update\` to move this machine to it.`
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Leave the refresh behind and return. The child is detached with no inherited
125
+ * stdio, so nothing it does can hold the session open or write into the
126
+ * terminal.
127
+ * @param {string} moduleFile @param {{spawn?: typeof spawn}} [options]
128
+ * @returns {boolean}
129
+ */
130
+ export function startUpdateProbe(moduleFile, options = {}) {
131
+ try {
132
+ const child = (options.spawn ?? spawn)(process.execPath, [moduleFile, "--probe"], {
133
+ detached: true,
134
+ stdio: "ignore",
135
+ windowsHide: true
136
+ });
137
+ child.unref();
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /** @returns {string|undefined} */
145
+ function fetchLatestVersion() {
146
+ const result = spawnSync("npm", ["view", HELPER_PACKAGE, "version", "--json"], {
147
+ encoding: "utf8",
148
+ timeout: PROBE_TIMEOUT_MS,
149
+ windowsHide: true
150
+ });
151
+ if (result.status !== 0 || typeof result.stdout !== "string") return undefined;
152
+ try {
153
+ return releaseVersion(JSON.parse(result.stdout));
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ /** @param {{now?: number, home?: string, fetch?: () => string|undefined}} [options] @returns {boolean} */
160
+ export function runUpdateProbe(options = {}) {
161
+ const latest = (options.fetch ?? fetchLatestVersion)();
162
+ // A registry that could not be reached leaves the cache alone, so the next
163
+ // session retries instead of recording "no update" as if it had been checked.
164
+ if (!latest) return false;
165
+ return writeUpdateCache(options.home, latest, options.now ?? Date.now());
166
+ }
167
+
168
+ if (process.argv[2] === "--probe" && process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
169
+ runUpdateProbe();
170
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.1.6",
3
+ "version": "1.1.8",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -6,19 +6,37 @@
6
6
  "required": [
7
7
  "schemaVersion",
8
8
  "projectId",
9
- "displayName",
10
- "mode",
11
- "rootMarkers",
12
- "protectedPaths",
13
- "requiredContext",
14
- "verifyCommands",
15
- "mcpCapabilities"
9
+ "displayName"
16
10
  ],
11
+ "if": {
12
+ "required": [
13
+ "extends"
14
+ ]
15
+ },
16
+ "then": {
17
+ "description": "A profile that names an adapter stores identity plus overrides. Every policy field it omits comes from the installed adapter at runtime, so none of them are required here."
18
+ },
19
+ "else": {
20
+ "required": [
21
+ "mode",
22
+ "rootMarkers",
23
+ "protectedPaths",
24
+ "requiredContext",
25
+ "verifyCommands",
26
+ "mcpCapabilities"
27
+ ]
28
+ },
17
29
  "properties": {
18
30
  "schemaVersion": {
19
31
  "type": "integer",
20
32
  "const": 1
21
33
  },
34
+ "extends": {
35
+ "description": "Name of a built-in adapter under adapters/ in the installed platform. Its profile is the base; every other key in this file replaces the adapter's key of the same name.",
36
+ "type": "string",
37
+ "minLength": 1,
38
+ "pattern": "^[a-z0-9][a-z0-9-]*$"
39
+ },
22
40
  "projectId": {
23
41
  "type": "string",
24
42
  "minLength": 1,
@@ -20,6 +20,10 @@ import {
20
20
  validateCapabilitySources,
21
21
  vendorRemoteSource
22
22
  } from "../packages/piagent-core/capabilities/capability-sources.js";
23
+ import {
24
+ buildExtendingProfile,
25
+ resolveProjectProfileDocument
26
+ } from "../packages/piagent-core/capabilities/project-profile.js";
23
27
 
24
28
  const platformRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
25
29
  const catalogPath = path.join(platformRoot, "catalog", "capabilities.json");
@@ -28,7 +32,7 @@ function usage() {
28
32
  process.stdout.write(`Usage:
29
33
  piagent-capabilities catalog [--check | --write]
30
34
  piagent-capabilities resolve --profile <profile.json> [--output <lock.json>] [--package-source <source>]
31
- piagent-capabilities apply-profile --profile <source.json> --target <piagent-profile.json> [--package-source <source>] [--force]
35
+ piagent-capabilities apply-profile --profile <source.json> --target <piagent-profile.json> [--extends <adapter>] [--package-source <source>] [--force]
32
36
  piagent-capabilities doctor [--profile <profile.json>] [--lock <lock.json>] [--package-source <source>]
33
37
  piagent-capabilities vendor --profile <profile.json> [--source <name>]
34
38
  piagent-capabilities validate-source --package-source <source>
@@ -58,7 +62,7 @@ function parseArguments(values) {
58
62
  output.flags.set(value, true);
59
63
  continue;
60
64
  }
61
- if (!["--profile", "--output", "--target", "--lock", "--file", "--package-source", "--source"].includes(value)) throw new CapabilityValidationError(`unknown option ${value}`);
65
+ if (!["--profile", "--extends", "--output", "--target", "--lock", "--file", "--package-source", "--source"].includes(value)) throw new CapabilityValidationError(`unknown option ${value}`);
62
66
  if (output.flags.has(value)) throw new CapabilityValidationError(`duplicate option ${value}`);
63
67
  const next = values[index + 1];
64
68
  if (!next || next.startsWith("--")) throw new CapabilityValidationError(`${value} requires a value`);
@@ -157,7 +161,7 @@ function runResolve(flags) {
157
161
  }
158
162
 
159
163
  function runApplyProfile(flags) {
160
- assertAllowedFlags(flags, new Set(["--profile", "--target", "--package-source", "--force"]));
164
+ assertAllowedFlags(flags, new Set(["--profile", "--extends", "--target", "--package-source", "--force"]));
161
165
  const source = requireExistingFile(flags.get("--profile"), "--profile");
162
166
  const targetValue = flags.get("--target");
163
167
  if (!targetValue) throw new CapabilityValidationError("--target is required");
@@ -166,17 +170,27 @@ function runApplyProfile(flags) {
166
170
  const parent = fs.realpathSync(path.dirname(target));
167
171
  const normalizedTarget = path.join(parent, path.basename(target));
168
172
  if (fs.existsSync(normalizedTarget) && !flags.has("--force")) throw new CapabilityValidationError("target profile already exists; pass --force to replace it");
169
- const profile = readJson(source);
173
+ const adapter = readJson(source);
174
+ const base = flags.get("--extends");
175
+ // With --extends the project records which adapter it follows and keeps only
176
+ // its own identity, so a later platform correction reaches it. Without it the
177
+ // source document is copied whole, which is what a caller supplying its own
178
+ // profile file wants.
179
+ const stored = base === undefined
180
+ ? adapter
181
+ : buildExtendingProfile(base, adapter.projectId, adapter.displayName);
182
+ const profile = resolveProjectProfileDocument(platformRoot, stored).profile;
170
183
  const packageSource = flags.get("--package-source") ?? "workspace";
171
184
  const lock = resolveCapabilityProfileDocument(platformRoot, profile, {
172
185
  profileFile: path.basename(normalizedTarget),
186
+ storedProfile: stored,
173
187
  packageSource,
174
188
  extraRoots: profile.capabilitySources === undefined
175
189
  ? undefined
176
190
  : resolveCapabilitySourceRoots(projectRootForProfile(normalizedTarget), profile.capabilitySources)
177
191
  });
178
192
  const lockTarget = path.join(parent, "piagent-profile.lock.json");
179
- writeProfileLockAtomic(normalizedTarget, profile, lockTarget, lock);
193
+ writeProfileLockAtomic(normalizedTarget, stored, lockTarget, lock);
180
194
  process.stdout.write(`${JSON.stringify({ ok: true, profile: normalizedTarget, lock: lockTarget, packs: lock.packs.length })}\n`);
181
195
  }
182
196
 
@@ -210,11 +224,18 @@ function runDoctor(flags) {
210
224
  const verification = verifyCapabilityLock(platformRoot, profile, readJson(lockFile), { packageSource, extraRoots });
211
225
  report.lock = {
212
226
  path: lockFile,
213
- current: verification.ok,
227
+ current: verification.status === "current",
228
+ status: verification.status,
229
+ reasons: verification.reasons,
214
230
  expectedDigest: verification.expectedDigest,
215
231
  actualDigest: verification.actualDigest
216
232
  };
217
- if (!verification.ok) throw new CapabilityValidationError("capability lock is stale or has been modified");
233
+ // A re-pinnable lock is behind the installed build but grants exactly what
234
+ // it agreed to, so it is reported rather than treated as a failure. The
235
+ // guard rewrites it on the next session.
236
+ if (verification.status === "blocked") {
237
+ throw new CapabilityValidationError(`capability lock no longer matches what the project agreed to: ${verification.reasons.join("; ")}`);
238
+ }
218
239
  }
219
240
  } else if (flags.has("--lock")) {
220
241
  throw new CapabilityValidationError("--lock requires --profile");
@@ -219,6 +219,13 @@ if [[ "$FORCE_PROFILE" == true || ! -f "$PROFILE_TARGET" ]]; then
219
219
  --target "$PROFILE_TARGET"
220
220
  --package-source "$PACKAGE_SOURCE"
221
221
  )
222
+ # A built-in adapter is referenced, not copied, so a later platform correction
223
+ # reaches this project. A caller supplying its own profile file still gets that
224
+ # file written whole.
225
+ if [[ "$PROFILE_PATH" == "$PLATFORM_ROOT/adapters/"*"/profile.json" ]]; then
226
+ adapter_name="${PROFILE_PATH#"$PLATFORM_ROOT/adapters/"}"
227
+ apply_profile_args+=(--extends "${adapter_name%/profile.json}")
228
+ fi
222
229
  if [[ "$FORCE_PROFILE" == true ]]; then
223
230
  apply_profile_args+=(--force)
224
231
  fi