@piagent/platform 1.1.6 → 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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  This file records release-facing changes for Pi Agent Platform. Copy the relevant version block into GitHub Releases when publishing a tag.
4
4
 
5
+ ## v1.1.7 - 2026-07-28
6
+
7
+ A platform update is one global install. It was not behaving like one: every project that selected capability packs stopped working after a release until its profile was reapplied by hand, and the policy a release corrected never reached a project that had already onboarded.
8
+
9
+ ### Changed
10
+
11
+ - A capability lock that is behind the installed platform is re-pinned instead of refusing the session. The lock pinned two different things and gave them one answer. What the project consented to — which packs, from where, and how far they may reach — still blocks: a grant that widens, a protection that disappears, a pack that changes identity. Which build produced the resolution — package version, runtime file digests, artifact content — moves on every release, and blocking on it put a per-project chore behind a global update while not defending against the case it resembles, since the code that verifies the lock is itself one of the files the lock pins. A build move with an unchanged grant is recorded and reported.
12
+
13
+ - Artifact content is part of the build, not part of consent. Adapter policy files, base policy, prompts, skills, subagents and recipes all ship as pack artifacts, so pinning their bytes meant a corrected policy could never reach a project referencing it. Nothing is given up: which artifacts a pack provides is declared in its manifest and the manifest digest is still pinned, so an artifact that appears, disappears or moves still blocks — and a policy artifact that weakens still blocks, because the permissions it resolves to are compared directionally.
14
+
15
+ - A project profile can name the adapter it takes its policy from instead of copying it. `{"extends": "web-frontend", "projectId": ..., "displayName": ...}` keeps the project's identity and overrides in the project, and resolves the rest from the installed platform at runtime. Any key the project states replaces the adapter's key whole; a profile without `extends` keeps working exactly as before, self-contained. `init-project.sh` and `/onboard-project` write this form when the profile comes from a built-in adapter, so onboarding stays the one per-project step and updating stays global.
16
+
17
+ A profile naming an adapter that is not installed refuses rather than falling back to something weaker.
18
+
5
19
  ## v1.1.6 - 2026-07-27
6
20
 
7
21
  Monorepo fixes found auditing how the platform scopes itself to a project. Every case was reproduced against a real directory tree before it was changed.
package/README.md CHANGED
@@ -283,7 +283,7 @@ This repository intentionally excludes:
283
283
 
284
284
  ## Maturity
285
285
 
286
- The current package version is read from package metadata and release tags. Personal machines may follow the unpinned package source when accepting ongoing updates; production/team quickstarts and committed project settings should pin an explicit tag such as `v1.1.6` or a reviewed commit.
286
+ The current package version is read from package metadata and release tags. Personal machines may follow the unpinned package source when accepting ongoing updates; production/team quickstarts and committed project settings should pin an explicit tag such as `v1.1.7` or a reviewed commit.
287
287
 
288
288
  Ready for:
289
289
 
@@ -153,6 +153,7 @@ Pack không thể cấp thêm quyền cho chính nó:
153
153
  - Doctor phát hiện catalog hoặc lock stale.
154
154
  - Generated output dùng temporary file cùng thư mục, `fsync`, rồi atomic rename.
155
155
  - Lock gắn với declared package-source identifier, package version, catalog và digest của các runtime enforcement file đang cài. Đây là integrity binding của runtime quan sát được, không phải chứng minh nguồn gốc cryptographic của package archive.
156
+ - Lock drift được phân loại hai nhánh, xem `## Lock drift` bên dưới.
156
157
  - Optional runtime package được cài bằng exact version; installer đối chiếu registry integrity với digest đã phê duyệt trước khi cài.
157
158
  - Khi cập nhật profile, lock được ghi trước; nếu profile không thể ghi thì lock được rollback. Process interruption tạo trạng thái mismatch fail-closed thay vì áp dụng profile thiếu lock.
158
159
 
@@ -166,6 +167,26 @@ Pack không thể cấp thêm quyền cho chính nó:
166
167
  - `validate-action` chỉ kiểm tra contract, thời hạn và secret indicator; không authorize action, không đối chiếu requested permission với profile/lock và không đọc artifact để xác minh digest/size.
167
168
  - External action vẫn cần human confirmation và executor riêng trước khi có side effect.
168
169
 
170
+ ## Lock drift
171
+
172
+ Lock pin hai thứ khác nhau, và khi lệch thì câu trả lời cũng khác nhau.
173
+
174
+ **Consent — project phải đồng ý lại, session bị chặn (`blocked`):**
175
+
176
+ - `schemaVersion` của lock, `core.apiVersion`, `core.packageSource`;
177
+ - `profile` — `projectId`, `mode`, tên file, digest của document project tự lưu;
178
+ - `packs` — name, version, origin, source, owner, lifecycle và digest của manifest. Manifest khai luôn `provides`, nên artifact xuất hiện thêm, biến mất hoặc đổi path đều nằm ở nhánh này;
179
+ - permission theo hướng: granting key (`capabilities`, `filesystemRead`, `filesystemWrite`, `networkDomains`, `externalActions`) chặn khi **thêm**; restricting key (`protectedPaths`, `readOnlyPaths`, `shellProtectedPaths`) chặn khi **mất**.
180
+
181
+ **Build — platform đổi bản, lock được ghi lại và báo, session chạy tiếp (`repin`):**
182
+
183
+ - `core.packageVersion`, `core.runtimeFiles`, `core.packageDigest`;
184
+ - nội dung artifact (`artifacts[].digest`).
185
+
186
+ Vì sao nội dung artifact nằm ở nhánh build: adapter policy file, base policy, prompt, skill, subagent và recipe đều ship dạng artifact. Pin bytes của chúng nghĩa là bản sửa policy không bao giờ tới được project đang tham chiếu — đúng thứ mà việc ship policy tập trung tồn tại để làm. Không mất gì khi bỏ pin: danh sách artifact nằm trong manifest và manifest digest vẫn được pin ở `packs`, còn policy artifact bị nới lỏng vẫn chặn vì permission sau resolve được so trực tiếp theo hướng.
187
+
188
+ Việc pin build cũng chưa bao giờ chống được trường hợp nó trông giống: code kiểm tra lock chính là một trong các file mà lock pin, nên thứ gì sửa được platform đã cài thì sửa luôn được checker.
189
+
169
190
  ## Lifecycle
170
191
 
171
192
  | Trạng thái | Ý nghĩa |
@@ -419,8 +419,8 @@ Các lệnh này chạy ngoài Pi.
419
419
  | Command | Dùng khi nào |
420
420
  |---|---|
421
421
  | `npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0` | Cài Pi CLI tương thích với release hiện tại. |
422
- | `npm install -g --ignore-scripts @piagent/platform@1.1.6` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
423
- | `pi install git:github.com/Vt-mmm/piagent@v1.1.6` | Cài pinned release khi cần reproducible team setup. |
422
+ | `npm install -g --ignore-scripts @piagent/platform@1.1.7` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
423
+ | `pi install git:github.com/Vt-mmm/piagent@v1.1.7` | Cài pinned release khi cần reproducible team setup. |
424
424
  | `pi install git:github.com/Vt-mmm/piagent` | Cài latest package platform cho máy cá nhân/sandbox. |
425
425
  | `piagent-update --check` | Báo version hiện tại vs version sẽ lên cho cả ba thành phần; không cài gì. |
426
426
  | `piagent-update` | Full update một lệnh: Pi host → npm-global helper → Pi package, đúng thứ tự release yêu cầu. Thêm `--project <path>` để chạy doctor sau. |
@@ -14,9 +14,9 @@ Repo này phải chạy được cho nhiều project/domain khác nhau. Vì vậ
14
14
 
15
15
  | Use case | Package source |
16
16
  |---|---|
17
- | Team pinned release | `git:github.com/Vt-mmm/piagent@v1.1.6` |
17
+ | Team pinned release | `git:github.com/Vt-mmm/piagent@v1.1.7` |
18
18
  | Personal/sandbox dev | `git:github.com/Vt-mmm/piagent` |
19
- | Registry distribution | `npm:@piagent/platform@1.1.6` |
19
+ | Registry distribution | `npm:@piagent/platform@1.1.7` |
20
20
  | Enterprise npm (fork under another scope) | `npm:@your-scope/platform@x.y.z` |
21
21
  | Local platform dev | `/path/to/piagent` |
22
22
 
@@ -26,7 +26,7 @@ Latest tiện cho máy cá nhân muốn nhận cập nhật nhanh. Pin tag/commi
26
26
 
27
27
  | Channel | Command | Target | Policy |
28
28
  |---|---|---|---|
29
- | `stable` | `bash scripts/install-global.sh --stable` | Helper release tag resolved to commit SHA, currently `v1.1.6` | Install the Pi package matching the helper. |
29
+ | `stable` | `bash scripts/install-global.sh --stable` | Helper release tag resolved to commit SHA, currently `v1.1.7` | Install the Pi package matching the helper. |
30
30
  | `exact` | `bash scripts/install-global.sh --version vX.Y.Z --resolve-tag` | Requested tag resolved to commit SHA | Pi-package-only rollout, rollback, and incident recovery. |
31
31
  | `dev` | `bash scripts/install-global.sh --dev` | Moving Git source | Personal/sandbox only; do not commit into project settings. |
32
32
  | `local` | `bash scripts/install-global.sh --local` | Current checkout path | Platform development only. |
@@ -53,7 +53,7 @@ Root `package.json` có `pi` manifest trỏ tới:
53
53
  Do đó team có thể:
54
54
 
55
55
  ```bash
56
- pi install git:github.com/Vt-mmm/piagent@v1.1.6
56
+ pi install git:github.com/Vt-mmm/piagent@v1.1.7
57
57
  ```
58
58
 
59
59
  Không cần biết internal folder `packages/piagent-core`. Source không pin chỉ dành cho personal/sandbox như bảng channel ở trên.
@@ -65,12 +65,12 @@ Team nên install global package một lần:
65
65
  ```bash
66
66
  node --version # >= 22.19.0
67
67
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
68
- npm install -g --ignore-scripts @piagent/platform@1.1.6
68
+ npm install -g --ignore-scripts @piagent/platform@1.1.7
69
69
  piagent-install --stable --dry-run
70
70
  piagent-install --stable
71
71
  ```
72
72
 
73
- `pi install git:github.com/Vt-mmm/piagent@v1.1.6` vẫn hợp lệ nếu chỉ cần cài Pi package. Lệnh đó không tự tạo các binary terminal `piagent-*`; muốn có helper global thì dùng `npm install -g --ignore-scripts @piagent/platform@1.1.6`.
73
+ `pi install git:github.com/Vt-mmm/piagent@v1.1.7` vẫn hợp lệ nếu chỉ cần cài Pi package. Lệnh đó không tự tạo các binary terminal `piagent-*`; muốn có helper global thì dùng `npm install -g --ignore-scripts @piagent/platform@1.1.7`.
74
74
 
75
75
  Support matrix hiện tại: macOS Apple Silicon + Bash và Linux x64 + Bash đã được verify; macOS Intel + Bash và Linux ARM64 + Bash là supported target cần chạy smoke trước rollout; native Windows chưa phải target rollout team; WSL2 experimental. Chi tiết và version runtime nằm trong [release/install policy](release-install-policy.md).
76
76
 
@@ -103,7 +103,7 @@ Nếu muốn commit sẵn `.pi/piagent-profile.json` vào repo hoặc bootstrap
103
103
  ```bash
104
104
  bash scripts/setup.sh /path/to/project \
105
105
  --profile be-readonly-fe \
106
- --package-source git:github.com/Vt-mmm/piagent@v1.1.6 \
106
+ --package-source git:github.com/Vt-mmm/piagent@v1.1.7 \
107
107
  --mcp-preset core \
108
108
  --subagents-preset safe
109
109
  ```
@@ -18,12 +18,12 @@ Support matrix release hiện tại: macOS Apple Silicon + Bash và Linux x64 +
18
18
  ```bash
19
19
  node --version # >= 22.19.0
20
20
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
21
- npm install -g --ignore-scripts @piagent/platform@1.1.6
21
+ npm install -g --ignore-scripts @piagent/platform@1.1.7
22
22
  piagent-install --stable --dry-run
23
23
  piagent-install --stable
24
24
  ```
25
25
 
26
- Khi seed `.pi/settings.json` cho team/repo cần audit lặp lại, giữ package source dạng pinned tag như `git:github.com/Vt-mmm/piagent@v1.1.6`. Máy cá nhân có thể dùng `git:github.com/Vt-mmm/piagent` để theo latest.
26
+ Khi seed `.pi/settings.json` cho team/repo cần audit lặp lại, giữ package source dạng pinned tag như `git:github.com/Vt-mmm/piagent@v1.1.7`. Máy cá nhân có thể dùng `git:github.com/Vt-mmm/piagent` để theo latest.
27
27
 
28
28
  Nếu đang ở source checkout của platform, dùng helper theo channel để preview trước khi đổi:
29
29
 
@@ -99,7 +99,7 @@ install package once
99
99
 
100
100
  ```bash
101
101
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
102
- npm install -g --ignore-scripts @piagent/platform@1.1.6
102
+ npm install -g --ignore-scripts @piagent/platform@1.1.7
103
103
  piagent-install --stable --dry-run
104
104
  piagent-install --stable
105
105
  ```
@@ -281,7 +281,7 @@ Không phải daily default. Dùng khi muốn tạo sẵn `.pi` files cho projec
281
281
  bash /path/to/piagent/scripts/setup.sh /path/to/project \
282
282
  --project-only \
283
283
  --profile auto \
284
- --package-source git:github.com/Vt-mmm/piagent@v1.1.6 \
284
+ --package-source git:github.com/Vt-mmm/piagent@v1.1.7 \
285
285
  --mcp-preset core \
286
286
  --subagents-preset safe
287
287
  ```
@@ -854,8 +854,8 @@ Watchdog là optional adversarial reviewer ở cuối turn, không bật mặc
854
854
 
855
855
  | Command | Dùng để |
856
856
  |---|---|
857
- | `npm install -g --ignore-scripts @piagent/platform@1.1.6` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
858
- | `pi install git:github.com/Vt-mmm/piagent@v1.1.6` | Install pinned release cho reproducible team setup. |
857
+ | `npm install -g --ignore-scripts @piagent/platform@1.1.7` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
858
+ | `pi install git:github.com/Vt-mmm/piagent@v1.1.7` | Install pinned release cho reproducible team setup. |
859
859
  | `pi install git:github.com/Vt-mmm/piagent` | Install latest platform package cho máy cá nhân/sandbox. |
860
860
  | Cài exact Pi host của release, rồi `npm install -g --ignore-scripts @piagent/platform@X.Y.Z` và `piagent-install --stable` | Full update: đồng bộ host, terminal helper và Pi package. Mỗi release pin một Pi host chính xác; lấy đúng version của release đang cài trong [release/install policy](release-install-policy.md). |
861
861
  | Cài exact Pi host ghi trong release cũ, rồi helper `vPREVIOUS` và `piagent-install --stable` | Full rollback; đánh giá lại dependency findings của host cũ trước khi hạ version. |
@@ -952,7 +952,7 @@ Mở lại Pi session sau khi install.
952
952
  Cài lại terminal helper đúng release rồi kiểm tra `PATH`:
953
953
 
954
954
  ```bash
955
- npm install -g --ignore-scripts @piagent/platform@1.1.6
955
+ npm install -g --ignore-scripts @piagent/platform@1.1.7
956
956
  command -v piagent-install
957
957
  ```
958
958
 
@@ -70,6 +70,29 @@ Path policy fields have distinct meanings:
70
70
 
71
71
  Glob trong các field này neo từ project root, nên `backend/**` chỉ khớp backend nằm ở root. Với monorepo, phải liệt kê đúng vị trí thật (`packages/api/**`, `services/*/**`). Không dùng `**/api/**`: nó khớp luôn `packages/web/src/api/` — thư mục HTTP client của frontend — và biến chính phần được phép sửa thành read-only.
72
72
 
73
+ ## Profile theo reference (`extends`)
74
+
75
+ Profile ở trên là dạng self-contained: project ghi toàn bộ policy. Dạng này đóng băng policy tại thời điểm onboard — platform sửa adapter thì project cũ vẫn giữ bản copy cũ mãi.
76
+
77
+ `extends` biến policy thành reference. Project chỉ giữ phần thuộc về project, phần còn lại lấy từ adapter trong platform đang cài:
78
+
79
+ ```json
80
+ {
81
+ "schemaVersion": 1,
82
+ "extends": "web-frontend",
83
+ "projectId": "my-project",
84
+ "displayName": "My Project"
85
+ }
86
+ ```
87
+
88
+ Quy tắc:
89
+
90
+ - `extends` trỏ tới một thư mục trong `adapters/` của platform đang cài. Tên không tồn tại → guard refuse, không fallback im lặng.
91
+ - Mọi key project tự khai **thay thế nguyên key** của adapter, không merge từng phần tử. Khai `protectedPaths` nghĩa là project sở hữu list đó và không nhận thay đổi từ adapter nữa.
92
+ - Không khai `extends` thì profile giữ nguyên hành vi cũ, self-contained.
93
+
94
+ `scripts/init-project.sh` và `/onboard-project` ghi dạng `extends` khi profile đến từ built-in adapter. Update platform sau đó không cần chạm vào project nào.
95
+
73
96
  ## Monorepo và workspace
74
97
 
75
98
  Một project = một thư mục mở `pi`. Guard đọc profile tại `<cwd>/.pi/piagent-profile.json` và không đi ngược lên tìm, nên mở `pi` ở thư mục cha chứa nhiều project sẽ bỏ qua profile của từng project con và chạy unprofiled.
@@ -49,6 +49,7 @@ pi install /path/to/piagent
49
49
  - `1.1.4`: nhận diện secret dạng CLI option (`--token`, `--password`, `--api-key`) và Authorization header ở mọi vị trí argument.
50
50
  - `1.1.5`: thêm `piagent-update` — một lệnh update cả Pi host, terminal helper và Pi package đúng thứ tự.
51
51
  - `1.1.6`: `be-readonly-fe` giữ được backend read-only trong monorepo, detect FE/BE đọc cả workspace, và rule detect gộp về một chỗ.
52
+ - `1.1.7`: update global không còn chặn project đang chạy — lock chỉ block khi grant thật sự đổi, và profile dạng `extends` nhận policy từ platform đang cài thay vì giữ bản copy lúc onboard.
52
53
 
53
54
  ## Không publish
54
55
 
@@ -10,7 +10,7 @@ This file is the canonical install, update, rollback, and release checklist. Oth
10
10
 
11
11
  All supported environments require Node.js `>=22.19.0` and Pi Coding Agent `0.82.0`. The Pi host is installed as a Node CLI; Pi Agent Platform still defines its own release matrix because the terminal helpers and shell policy rely on Bash/POSIX behavior.
12
12
 
13
- | Surface | Status for v1.1.6 | Rollout guidance |
13
+ | Surface | Status for v1.1.7 | Rollout guidance |
14
14
  |---|---|---|
15
15
  | macOS Apple Silicon (`darwin/arm64`) + Bash | Verified for this release. | Safe default for team rollout after normal project smoke tests. |
16
16
  | Linux x64 + Bash | Verified in GitHub Actions. | Safe default for CI/server usage after normal project smoke tests. |
@@ -33,7 +33,7 @@ These three components are versioned independently. A full install, update, or r
33
33
 
34
34
  | Channel | Source shape | Mutability | Use when |
35
35
  |---|---|---:|---|
36
- | `stable` | `git:github.com/Vt-mmm/piagent@<resolved-commit-sha>` from `v1.1.6` | Fixed after resolution | Default for team rollout. |
36
+ | `stable` | `git:github.com/Vt-mmm/piagent@<resolved-commit-sha>` from `v1.1.7` | Fixed after resolution | Default for team rollout. |
37
37
  | `exact` | Tag, reviewed commit, or a tag resolved with `--resolve-tag` | Fixed when using a commit SHA | Pi-package-only roll forward, rollback, or reproduction. |
38
38
  | `dev` | `git:github.com/Vt-mmm/piagent` | Moving | Personal machine or sandbox only. |
39
39
  | `local` | `/path/to/piagent` | Local workspace | Platform development and dry-run validation. |
@@ -53,7 +53,7 @@ Use this flow when the team needs both terminal commands and the Pi package:
53
53
  ```bash
54
54
  node --version # >= 22.19.0
55
55
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
56
- npm install -g --ignore-scripts @piagent/platform@1.1.6
56
+ npm install -g --ignore-scripts @piagent/platform@1.1.7
57
57
  piagent-install --stable --dry-run
58
58
  piagent-install --stable
59
59
  ```
@@ -61,8 +61,8 @@ piagent-install --stable
61
61
  The stable preview and apply output includes:
62
62
 
63
63
  ```text
64
- currentRelease: v1.1.6 (helper package version)
65
- tag: v1.1.6
64
+ currentRelease: v1.1.7 (helper package version)
65
+ tag: v1.1.7
66
66
  resolvedCommit: <40-char-sha>
67
67
  source: git:github.com/Vt-mmm/piagent@<40-char-sha>
68
68
  ```
@@ -79,7 +79,7 @@ bash scripts/install-global.sh --stable
79
79
  Use this only when terminal commands are not needed:
80
80
 
81
81
  ```bash
82
- pi install git:github.com/Vt-mmm/piagent@v1.1.6
82
+ pi install git:github.com/Vt-mmm/piagent@v1.1.7
83
83
  ```
84
84
 
85
85
  Direct `pi install` does not create `piagent-*` commands on `PATH`.
@@ -21,7 +21,7 @@ This document covers the packaged guard, project profiles, capability locks, ins
21
21
  - The operator, operating-system account, Pi host, and explicitly trusted project repository are trusted to execute code.
22
22
  - Project-local Pi resources are loaded only after Pi project trust or an explicit operator override.
23
23
  - A human remains available to approve destructive and external-provider actions.
24
- - Supported release verification covers macOS Apple Silicon + Bash, Linux x64 + Bash, Node.js `>=22.19.0`, and the exact Pi host version declared by the release. macOS Intel + Bash and Linux ARM64 + Bash are supported targets that need local smoke verification before broad rollout. Native Windows is not a team-rollout target for v1.1.6, and WSL2 is experimental/unverified.
24
+ - Supported release verification covers macOS Apple Silicon + Bash, Linux x64 + Bash, Node.js `>=22.19.0`, and the exact Pi host version declared by the release. macOS Intel + Bash and Linux ARM64 + Bash are supported targets that need local smoke verification before broad rollout. Native Windows is not a team-rollout target for v1.1.7, and WSL2 is experimental/unverified.
25
25
  - Registry, GitHub, model-provider, MCP-provider, and Vercel controls are external dependencies. Their account security and platform guarantees are not replaced by this repository.
26
26
 
27
27
  ## Threat actors and failure modes
@@ -9,7 +9,7 @@ Một thành viên mới không cần biết local path của maintainer. Luồn
9
9
  ```bash
10
10
  node --version # >= 22.19.0
11
11
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
12
- npm install -g --ignore-scripts @piagent/platform@1.1.6
12
+ npm install -g --ignore-scripts @piagent/platform@1.1.7
13
13
  piagent-install --stable
14
14
  cd /path/to/project
15
15
  pi
@@ -42,7 +42,7 @@ Mặc định team dùng stable helper:
42
42
  ```bash
43
43
  node --version # >= 22.19.0
44
44
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
45
- npm install -g --ignore-scripts @piagent/platform@1.1.6
45
+ npm install -g --ignore-scripts @piagent/platform@1.1.7
46
46
  piagent-install --stable --dry-run
47
47
  piagent-install --stable
48
48
  ```
@@ -70,7 +70,7 @@ piagent-update --check
70
70
  piagent-update --project /path/to/project
71
71
  ```
72
72
 
73
- `--check` chỉ báo version, không đụng gì. Lên đúng một bản cụ thể thì `piagent-update --version 1.1.6`.
73
+ `--check` chỉ báo version, không đụng gì. Lên đúng một bản cụ thể thì `piagent-update --version 1.1.7`.
74
74
 
75
75
  Sequence thủ công vẫn dùng được, và bắt buộc theo thứ tự này — `piagent-install` **fail** nếu Pi host lệch bản pin, nó không tự nâng host:
76
76
 
@@ -798,7 +798,7 @@
798
798
  <span>Pi Agent Platform</span>
799
799
  </a>
800
800
  <div class="top-actions">
801
- <span class="pill"><span class="pill-dot"></span>v1.1.6 docs</span>
801
+ <span class="pill"><span class="pill-dot"></span>v1.1.7 docs</span>
802
802
  <a class="button social" href="https://github.com/Vt-mmm/piagent" target="_blank" rel="noopener noreferrer" aria-label="Mở GitHub repository">
803
803
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
804
804
  <path d="M7 17 17 7M9 7h8v8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
@@ -934,7 +934,7 @@
934
934
  <article class="step">
935
935
  <div>
936
936
  <h3>Cài — hai lệnh</h3>
937
- <p>Cần Node.js 22.19.0 trở lên. Không cần cài Pi Coding Agent trước: <span class="inline-code">piagent-setup</span> tự cài đúng bản <span class="inline-code">0.82.0</span> mà release <span class="inline-code">v1.1.6</span> đang pin. Chạy nó ngay trong thư mục project — nó cài Pi package, khởi tạo <span class="inline-code">.pi/</span>, cài MCP baseline cùng subagents, rồi chạy doctor. Thêm <span class="inline-code">--no-mcp</span>, <span class="inline-code">--no-subagents</span>, <span class="inline-code">--no-herdr</span> hoặc <span class="inline-code">--global-only</span> để bỏ bớt.</p>
937
+ <p>Cần Node.js 22.19.0 trở lên. Không cần cài Pi Coding Agent trước: <span class="inline-code">piagent-setup</span> tự cài đúng bản <span class="inline-code">0.82.0</span> mà release <span class="inline-code">v1.1.7</span> đang pin. Chạy nó ngay trong thư mục project — nó cài Pi package, khởi tạo <span class="inline-code">.pi/</span>, cài MCP baseline cùng subagents, rồi chạy doctor. Thêm <span class="inline-code">--no-mcp</span>, <span class="inline-code">--no-subagents</span>, <span class="inline-code">--no-herdr</span> hoặc <span class="inline-code">--global-only</span> để bỏ bớt.</p>
938
938
  <div class="table-wrap compact-table">
939
939
  <table>
940
940
  <thead>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/platform",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { containsSensitiveText } from "../security/sensitive-data.js";
5
+ import { resolveProjectProfileDocument } from "./project-profile.js";
5
6
 
6
7
  const API_VERSION = "piagent/v1";
7
8
  const CORE_API_VERSION = 1;
@@ -785,6 +786,9 @@ function buildCoreIntegrity(root) {
785
786
  // capability-sources.js decides which external trees become readable packs,
786
787
  // so tampering with it changes which code the profile can grant.
787
788
  "packages/piagent-core/capabilities/capability-sources.js",
789
+ // project-profile.js decides which document is enforced when a project takes
790
+ // its policy from an adapter, so it selects the protected paths themselves.
791
+ "packages/piagent-core/capabilities/project-profile.js",
788
792
  "packages/piagent-core/security/sensitive-data.js",
789
793
  "packages/piagent-core/extensions/piagent-guard.ts",
790
794
  // guard-io.js loads the project profile and guard-shell-analysis.ts decides
@@ -904,7 +908,7 @@ export function resolveCapabilityProfileDocument(root, profile, options = {}) {
904
908
  projectId: profile.projectId,
905
909
  mode: profile.mode,
906
910
  file: profileFile,
907
- digest: sha256(stableJson(profile))
911
+ digest: sha256(stableJson(options.storedProfile ?? profile))
908
912
  },
909
913
  packs: [...selected.values()].map((pack) => ({
910
914
  name: pack.name,
@@ -927,25 +931,118 @@ export function resolveCapabilityProfileDocument(root, profile, options = {}) {
927
931
 
928
932
  export function resolveCapabilityProfile(root, profilePath, options = {}) {
929
933
  const profileAbsolute = fs.realpathSync(profilePath);
930
- const profile = readJsonFile(profileAbsolute);
934
+ const stored = readJsonFile(profileAbsolute);
935
+ const { profile } = resolveProjectProfileDocument(fs.realpathSync(root), stored);
931
936
  return resolveCapabilityProfileDocument(root, profile, {
932
937
  profileFile: path.basename(profileAbsolute),
938
+ // The lock records the document the project owns. A profile that takes its
939
+ // policy from an adapter is meant to follow that adapter, so digesting the
940
+ // resolved document instead would turn every platform correction into a
941
+ // lock the project has to re-agree to by hand.
942
+ storedProfile: stored,
933
943
  packageSource: options.packageSource,
934
944
  extraRoots: options.extraRoots
935
945
  });
936
946
  }
937
947
 
948
+ // A lock pins two different things and they deserve different answers when they
949
+ // stop matching.
950
+ //
951
+ // What the project consented to: which packs, from where, and how far they may
952
+ // reach. Granting more than that, or covering less than that, is a change the
953
+ // project has to agree to, so it blocks.
954
+ //
955
+ // Which build of the platform produced the resolution: its version, the digests
956
+ // of its runtime files, and the content of the artifacts its packs ship. That
957
+ // moves on every release. Blocking on it put a per-project chore behind a global
958
+ // update — every project on a profile that selects packs stopped working until
959
+ // its profile was reapplied — while not actually defending against the case it
960
+ // resembles: the code that verifies the lock is itself one of the files the lock
961
+ // pins, so anything able to rewrite the installed platform can rewrite the check
962
+ // along with it. A build move with an unchanged grant is re-pinned and reported,
963
+ // not refused.
964
+ //
965
+ // Artifact content sits on the build side deliberately. Adapter policy files,
966
+ // base policy, prompts, skills, subagents, and recipes all ship as artifacts, so
967
+ // pinning their bytes means a policy correction cannot reach a project that
968
+ // references it — which is the whole point of shipping policy centrally. Nothing
969
+ // is given up by that: which artifacts a pack provides is declared in its
970
+ // manifest, and the manifest digest is pinned in `packs`, so an artifact that
971
+ // appears, disappears, or moves still blocks. A policy artifact that weakens
972
+ // blocks too, because the permissions it resolves to are compared directly
973
+ // below.
974
+ const GRANTING_PERMISSIONS = ["capabilities", "filesystemRead", "filesystemWrite", "networkDomains", "externalActions"];
975
+ const RESTRICTING_PERMISSIONS = ["protectedPaths", "readOnlyPaths", "shellProtectedPaths"];
976
+
977
+ function consentedShape(document) {
978
+ return {
979
+ schemaVersion: document?.schemaVersion,
980
+ apiVersion: document?.core?.apiVersion,
981
+ packageSource: document?.core?.packageSource,
982
+ profile: document?.profile,
983
+ packs: document?.packs
984
+ };
985
+ }
986
+
987
+ function consentDrift(expected, actual) {
988
+ const before = consentedShape(actual);
989
+ const after = consentedShape(expected);
990
+ return Object.keys(after)
991
+ .filter((key) => stableJson(after[key]) !== stableJson(before[key]))
992
+ .map((key) => `${key} no longer matches the locked value`);
993
+ }
994
+
995
+ function permissionDrift(expected, actual) {
996
+ const locked = actual?.permissions ?? {};
997
+ const resolved = expected?.permissions ?? {};
998
+ const drift = [];
999
+
1000
+ for (const key of GRANTING_PERMISSIONS) {
1001
+ const agreed = new Set(locked[key] ?? []);
1002
+ const added = (resolved[key] ?? []).filter((item) => !agreed.has(item));
1003
+ if (added.length > 0) drift.push(`${key} would grant ${added.join(", ")}`);
1004
+ }
1005
+ // A protection that disappears is a weakening even though the list shrank.
1006
+ for (const key of RESTRICTING_PERMISSIONS) {
1007
+ const kept = new Set(resolved[key] ?? []);
1008
+ const dropped = (locked[key] ?? []).filter((item) => !kept.has(item));
1009
+ if (dropped.length > 0) drift.push(`${key} would stop covering ${dropped.join(", ")}`);
1010
+ }
1011
+ return drift;
1012
+ }
1013
+
1014
+ export function classifyCapabilityLock(expected, actual) {
1015
+ const expectedDigest = sha256(stableJson(expected));
1016
+ const actualDigest = sha256(stableJson(actual));
1017
+ if (crypto.timingSafeEqual(Buffer.from(expectedDigest), Buffer.from(actualDigest))) {
1018
+ return { status: "current", reasons: [], expectedDigest, actualDigest };
1019
+ }
1020
+
1021
+ const reasons = [...consentDrift(expected, actual), ...permissionDrift(expected, actual)];
1022
+ if (reasons.length > 0) return { status: "blocked", reasons, expectedDigest, actualDigest };
1023
+
1024
+ return {
1025
+ status: "repin",
1026
+ reasons: [`platform build moved to ${expected?.core?.packageVersion ?? "an unknown version"}`],
1027
+ expectedDigest,
1028
+ actualDigest
1029
+ };
1030
+ }
1031
+
938
1032
  export function verifyCapabilityLock(root, profilePath, lockDocument, options = {}) {
939
1033
  const expected = resolveCapabilityProfile(root, profilePath, {
940
1034
  packageSource: options.packageSource,
941
1035
  extraRoots: options.extraRoots
942
1036
  });
943
- const expectedText = stableJson(expected);
944
- const actualText = stableJson(lockDocument);
1037
+ const classification = classifyCapabilityLock(expected, lockDocument);
945
1038
  return {
946
- ok: crypto.timingSafeEqual(Buffer.from(sha256(expectedText)), Buffer.from(sha256(actualText))),
947
- expectedDigest: sha256(expectedText),
948
- actualDigest: sha256(actualText),
1039
+ // `ok` stays the question callers were already asking: may this project run?
1040
+ // A re-pinnable lock may, and the caller is told so through `status`.
1041
+ ok: classification.status !== "blocked",
1042
+ status: classification.status,
1043
+ reasons: classification.reasons,
1044
+ expectedDigest: classification.expectedDigest,
1045
+ actualDigest: classification.actualDigest,
949
1046
  expected
950
1047
  };
951
1048
  }
@@ -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,11 @@ import {
40
40
  redactSensitiveText
41
41
  } from "./redaction-core.js";
42
42
  import { detectProfileName } from "./project-shape.js";
43
+ import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
43
44
  import {
44
45
  resolveCapabilityProfileDocument,
45
46
  verifyCapabilityLock,
47
+ writeJsonAtomic,
46
48
  writeProfileLockAtomic
47
49
  } from "../capabilities/capability-core.js";
48
50
  import { resolveCapabilitySourceRoots } from "../capabilities/capability-sources.js";
@@ -417,6 +419,10 @@ const DEFAULT_POLICY: BasePolicy = {
417
419
  orchestrationPolicy: DEFAULT_ORCHESTRATION_POLICY
418
420
  };
419
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
+
420
426
  function loadPolicy(extensionDir: string): BasePolicy {
421
427
  const root = findPackageRoot(extensionDir);
422
428
  return readJsonFile<BasePolicy>(path.join(root, "policies", "base-policy.json")) ?? DEFAULT_POLICY;
@@ -433,17 +439,38 @@ function fallbackProfile(cwd: string, mode = "unprofiled"): ProjectProfile {
433
439
  };
434
440
  }
435
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
+
436
463
  function loadProfile(cwd: string, projectTrusted = false): ProjectProfile {
437
464
  const explicit = process.env.PIAGENT_PROFILE;
438
465
  if (explicit && explicit.trim().length > 0) {
439
- return readJsonFile<ProjectProfile>(explicit) ?? fallbackProfile(cwd, "explicit-profile-unreadable");
466
+ return resolveStoredProfile(readJsonFile<ProjectProfile>(explicit), cwd, "explicit-profile-unreadable");
440
467
  }
441
468
 
442
469
  if (!projectTrusted) {
443
470
  return fallbackProfile(cwd, "unprofiled-global-package");
444
471
  }
445
472
 
446
- 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");
447
474
  }
448
475
 
449
476
  function loadProfileFromContext(ctx: ExtensionContext): ProjectProfile {
@@ -530,6 +557,7 @@ function capabilitySourceRoots(
530
557
  type ProjectCapabilityState = {
531
558
  ok: boolean;
532
559
  reason?: string;
560
+ repinned?: string;
533
561
  filesystemRead?: string[];
534
562
  filesystemWrite?: string[];
535
563
  };
@@ -539,8 +567,18 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
539
567
  if (!projectTrusted) return { ok: true };
540
568
  const profilePath = projectProfilePath(cwd);
541
569
  if (!fs.existsSync(profilePath)) return { ok: true };
542
- const profile = readJsonFile<ProjectProfile>(profilePath);
543
- 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 };
544
582
  const lockPath = path.join(cwd, ".pi", "piagent-profile.lock.json");
545
583
  if (!fs.existsSync(lockPath)) return { ok: false, reason: "Capability lock is missing. Reapply the project profile." };
546
584
  const lock = readJsonFile<Record<string, unknown>>(lockPath);
@@ -550,13 +588,30 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
550
588
  packageSource: projectPackageSource(cwd),
551
589
  extraRoots: capabilitySourceRoots(cwd, profile)
552
590
  });
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." };
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 };
560
615
  } catch (error) {
561
616
  return { ok: false, reason: `Capability validation failed: ${error instanceof Error ? error.message : String(error)}` };
562
617
  }
@@ -2854,12 +2909,18 @@ function buildTechStackManifest(profileName: string, selectedOptions: TechOption
2854
2909
  };
2855
2910
  }
2856
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.
2857
2915
  function writeProfileDocumentWithLock(extensionDir: string, cwd: string, profile: ProjectProfile): ProjectProfile {
2858
2916
  const target = projectProfilePath(cwd);
2859
- 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, {
2860
2920
  profileFile: "piagent-profile.json",
2921
+ storedProfile: profile,
2861
2922
  packageSource: projectPackageSource(cwd),
2862
- extraRoots: capabilitySourceRoots(cwd, profile)
2923
+ extraRoots: capabilitySourceRoots(cwd, resolved)
2863
2924
  });
2864
2925
  fs.mkdirSync(path.dirname(target), { recursive: true });
2865
2926
  writeProfileLockAtomic(target, profile, path.join(cwd, ".pi", "piagent-profile.lock.json"), capabilityLock);
@@ -2909,7 +2970,7 @@ function writeTechStackSelection(
2909
2970
  };
2910
2971
  writeProfileDocumentWithLock(extensionDir, cwd, profile);
2911
2972
  ensureProjectContextPlaceholder(cwd);
2912
- return { profile, manifest };
2973
+ return { profile: resolveProjectProfileDocument(findPlatformRoot(extensionDir), profile).profile as ProjectProfile, manifest };
2913
2974
  }
2914
2975
 
2915
2976
  function normalizeTechSelections(cwd: string, profileName: string, selections: Record<string, unknown> = {}, fillRecommended = true): {
@@ -3033,20 +3094,24 @@ function buildProfileFromAdapter(extensionDir: string, cwd: string, profileName:
3033
3094
  const profile = readJsonFile<ProjectProfile>(source);
3034
3095
  if (!profile) throw new Error(`Profile unreadable: ${profileName}`);
3035
3096
  const projectName = path.basename(cwd);
3036
- return {
3037
- ...profile,
3038
- projectId: projectId ? slugify(projectId) : slugify(projectName),
3039
- displayName: displayName?.trim() || titleize(projectName)
3040
- };
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;
3041
3104
  }
3042
3105
 
3043
3106
  function writeProfileFromAdapter(extensionDir: string, cwd: string, profileName: string, overwrite = false, projectId?: string, displayName?: string): ProjectProfile {
3044
3107
  const target = projectProfilePath(cwd);
3045
3108
  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);
3109
+ const stored = buildProfileFromAdapter(extensionDir, cwd, profileName, projectId, displayName);
3110
+ writeProfileDocumentWithLock(extensionDir, cwd, stored);
3048
3111
  ensureProjectContextPlaceholder(cwd);
3049
- 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;
3050
3115
  }
3051
3116
 
3052
3117
  function readTask(cwd: string, taskId: string): TaskContract | undefined {
@@ -3659,6 +3724,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
3659
3724
  const permissionHint = ` permission=${permissionProfile.mode}`;
3660
3725
  ctx.ui.notify(`Piagent Pi guard loaded: ${name}${profileHint}${permissionHint}${contextHint}`, preflight.recommendation === "fresh-session" ? "warning" : "info");
3661
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
+ }
3662
3730
  const legacyWarning = legacyProjectStateWarning(ctx.cwd);
3663
3731
  if (legacyWarning) ctx.ui.notify(legacyWarning, "warning");
3664
3732
  if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
@@ -4675,10 +4743,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
4675
4743
  entry.context7.digest = snapshot.digest;
4676
4744
  manifest.updatedAt = nowIso();
4677
4745
  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);
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);
4682
4752
  }
4683
4753
  appendTrace(ctx.cwd, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
4684
4754
  appendSessionTrace(pi, { event: "profile_tech_context_record", techId, role: entry.role, libraryId: snapshot.resolvedLibraryId });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
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
@@ -15,7 +15,7 @@ Package source examples:
15
15
  # Installs the stable tag after resolving it to a commit SHA.
16
16
 
17
17
  # Pin a specific release:
18
- scripts/install-global.sh --version v1.1.6 --resolve-tag
18
+ scripts/install-global.sh --version v1.1.7 --resolve-tag
19
19
 
20
20
  # Preview the planned install/update without changing user config:
21
21
  scripts/install-global.sh --stable --dry-run
@@ -127,7 +127,7 @@ detect_runtime_surface() {
127
127
  printf 'native-windows/%s (not release-gated; terminal helpers require Bash semantics)' "$arch_name"
128
128
  ;;
129
129
  *)
130
- printf '%s/%s (outside v1.1.6 release matrix)' "$os_name" "$arch_name"
130
+ printf '%s/%s (outside v1.1.7 release matrix)' "$os_name" "$arch_name"
131
131
  ;;
132
132
  esac
133
133
  }
@@ -135,7 +135,7 @@ detect_runtime_surface() {
135
135
  warn_runtime_surface_if_needed() {
136
136
  local surface="$1"
137
137
  case "$surface" in
138
- *"supported target"*|*"experimental"*|*"not release-gated"*|*"outside v1.1.6 release matrix"*)
138
+ *"supported target"*|*"experimental"*|*"not release-gated"*|*"outside v1.1.7 release matrix"*)
139
139
  echo "WARN: runtime surface is $surface." >&2
140
140
  echo "WARN: For team rollout, run piagent-doctor plus the project smoke/verify suite on this machine before relying on it." >&2
141
141
  ;;
@@ -19,12 +19,22 @@ if [[ ! -f "$PROFILE_PATH" ]]; then
19
19
  exit 1
20
20
  fi
21
21
 
22
- node --input-type=module - "$PROJECT_PATH" "$PROFILE_PATH" <<'NODE'
22
+ PLATFORM_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
23
+
24
+ node --input-type=module - "$PROJECT_PATH" "$PROFILE_PATH" "$PLATFORM_ROOT" <<'NODE'
23
25
  import fs from "node:fs";
24
26
  import path from "node:path";
27
+ import { pathToFileURL } from "node:url";
28
+
29
+ const [projectPath, profilePath, platformRoot] = process.argv.slice(2);
30
+ const stored = JSON.parse(fs.readFileSync(profilePath, "utf8"));
25
31
 
26
- const [projectPath, profilePath] = process.argv.slice(2);
27
- const profile = JSON.parse(fs.readFileSync(profilePath, "utf8"));
32
+ // A profile that names an adapter is checked as the document that will actually
33
+ // be enforced, not as the few lines the project happens to store.
34
+ const { resolveProjectProfileDocument } = await import(
35
+ pathToFileURL(path.join(platformRoot, "packages", "piagent-core", "capabilities", "project-profile.js")).href
36
+ );
37
+ const profile = resolveProjectProfileDocument(platformRoot, stored).profile;
28
38
 
29
39
  const errors = [];
30
40
  const warnings = [];
@@ -25,11 +25,20 @@ bash "$ROOT/scripts/profile-doctor.sh" "$PROJECT" >/dev/null
25
25
  bash "$ROOT/scripts/quality-benchmark.sh" "$PROJECT" --init >/dev/null
26
26
  bash "$ROOT/scripts/quality-benchmark.sh" "$PROJECT" --record --scenario smoke --surface pi --result pass --tokens 1 --verify "test -s README.md" >/dev/null
27
27
 
28
- node --input-type=module - "$PROJECT/.pi/piagent-profile.json" "$PROJECT/.pi/benchmarks/quality-runs.jsonl" <<'NODE'
28
+ node --input-type=module - "$PROJECT/.pi/piagent-profile.json" "$PROJECT/.pi/benchmarks/quality-runs.jsonl" "$ROOT" <<'NODE'
29
29
  import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { pathToFileURL } from "node:url";
30
32
 
31
- const [profilePath, benchmarkPath] = process.argv.slice(2);
32
- const profile = JSON.parse(fs.readFileSync(profilePath, "utf8"));
33
+ const [profilePath, benchmarkPath, platformRoot] = process.argv.slice(2);
34
+ const stored = JSON.parse(fs.readFileSync(profilePath, "utf8"));
35
+
36
+ // A project that names an adapter stores identity and overrides only. The policy
37
+ // the runtime enforces is the resolved document, so that is what gets checked.
38
+ const { resolveProjectProfileDocument } = await import(
39
+ pathToFileURL(path.join(platformRoot, "packages", "piagent-core", "capabilities", "project-profile.js")).href
40
+ );
41
+ const profile = resolveProjectProfileDocument(platformRoot, stored).profile;
33
42
  for (const capability of ["filesystem-readonly", "filesystem-write", "shell", "memory"]) {
34
43
  if (!profile.mcpCapabilities.includes(capability)) {
35
44
  throw new Error(`missing capability ${capability}`);
@@ -137,7 +137,7 @@ function runtimeMatrixStatus() {
137
137
  surface: `${platform}/${arch}`,
138
138
  status: "outside-release-matrix",
139
139
  teamRolloutReady: false,
140
- note: "This runtime is outside the documented v1.1.6 support matrix."
140
+ note: "This runtime is outside the documented v1.1.7 support matrix."
141
141
  };
142
142
  }
143
143
 
@@ -313,7 +313,12 @@ if (foreignAgentConfig.length > 0) {
313
313
  const projectProfilePath = path.join(projectPath, ".pi", "piagent-profile.json");
314
314
  let projectProfile = null;
315
315
  if (fs.existsSync(projectProfilePath)) {
316
- const profile = readJson(projectProfilePath);
316
+ // Check the document that will be enforced. A profile that names an adapter
317
+ // stores only its own identity and overrides.
318
+ const { resolveProjectProfileDocument } = await import(
319
+ pathToFileURL(path.join(platformRoot, "packages", "piagent-core", "capabilities", "project-profile.js")).href
320
+ );
321
+ const profile = resolveProjectProfileDocument(platformRoot, readJson(projectProfilePath)).profile;
317
322
  projectProfile = profile;
318
323
  for (const field of ["schemaVersion", "projectId", "displayName", "mode"]) {
319
324
  if (profile[field] === undefined || profile[field] === "") errors.push(`project profile missing ${field}`);
@@ -333,7 +338,11 @@ if (fs.existsSync(projectProfilePath)) {
333
338
  try {
334
339
  const lock = readJson(projectLockPath);
335
340
  const verification = verifyCapabilityLock(platformRoot, projectProfilePath, lock, { packageSource: projectPackageSource });
336
- if (!verification.ok) errors.push("project capability lock is stale or does not match the configured package source");
341
+ if (verification.status === "blocked") {
342
+ errors.push(`project capability lock no longer matches what the project agreed to: ${verification.reasons.join("; ")}`);
343
+ } else if (verification.status === "repin") {
344
+ warnings.push(`project capability lock is behind the installed platform and will be re-pinned on the next Pi session (${verification.reasons.join("; ")})`);
345
+ }
337
346
  } catch (error) {
338
347
  errors.push(`project capability lock validation failed: ${error instanceof Error ? error.message : String(error)}`);
339
348
  }